Add proxy actor safety test and doc update
Introduce a game-thread-only ProxyActorController that resolves and moves a placed proxy actor in a test cell for initial visual-control validation. Adds include/F4TProxyActorController.h and plugin/src/F4TProxyActorController.cpp which implement editor ID, base-actor, and fallback-form lookup paths, handle caching, throttled logging, movement throttling, and a simple player-offset teleport. Hooked UpdateSafetyTest into the existing periodic game-thread task in main.cpp. Updated docs/dev-log.md with the 2026-05-31 entry describing the test results and next steps. This is a temporary safety test (uses local player offset) intended to be replaced by real remote-player syncing later.
This commit is contained in:
@@ -572,3 +572,38 @@ Use this format for future updates:
|
|||||||
- ...
|
- ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 2026-05-31
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
- Fixed proxy actor lookup for `F4TProxyRemotePlayer01REF`.
|
||||||
|
- Added more robust lookup logic for the placed proxy actor.
|
||||||
|
- Confirmed the plugin can control a placed actor reference in `F4TTestCell01`.
|
||||||
|
- The proxy actor now moves to the local player position plus an offset.
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
|
||||||
|
- Fallout 4 launched through F4SE.
|
||||||
|
- `coc F4TTestCell01` worked.
|
||||||
|
- The proxy actor was found successfully.
|
||||||
|
- The proxy actor teleported to the player position plus offset.
|
||||||
|
- Actor movement occurred from the safe game-thread update path.
|
||||||
|
- The networking receive thread still does not touch Fallout 4 actors.
|
||||||
|
|
||||||
|
### What Broke
|
||||||
|
|
||||||
|
- Nothing recorded.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This is the first successful visible actor-control test for Fallout 4 Together.
|
||||||
|
- The proxy is still driven by local player position plus offset, not remote player state.
|
||||||
|
- Dynamic spawning is still not implemented.
|
||||||
|
- Remote actor syncing has not started yet.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- Replace local-player-offset movement with remote player state movement.
|
||||||
|
- Only move the proxy when the remote player is in the same cell/worldspace.
|
||||||
|
- Snap movement first.
|
||||||
|
- Add smoothing/interpolation later.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace F4T::ProxyActorController
|
||||||
|
{
|
||||||
|
// Temporary game-thread-only actor-control safety test. This validates that the
|
||||||
|
// plugin can resolve and move the placed proxy before remote-player visual sync.
|
||||||
|
void UpdateSafetyTest();
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#include "pch.h"
|
||||||
|
|
||||||
|
#include "F4TProxyActorController.h"
|
||||||
|
|
||||||
|
#include "F4TNetworking.h"
|
||||||
|
|
||||||
|
#include <format>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr auto kTestCellEditorId = "F4TTestCell01";
|
||||||
|
constexpr auto kProxyEditorId = "F4TProxyRemotePlayer01REF";
|
||||||
|
constexpr auto kProxyBaseActorEditorId = "F4T_RemotePlayerProxy";
|
||||||
|
constexpr auto kTestPluginName = "Fallout4Together_Test.esp";
|
||||||
|
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
|
||||||
|
constexpr auto kProxyOffsetX = 150.0F;
|
||||||
|
constexpr auto kMovementInterval = 200ms;
|
||||||
|
constexpr auto kWarningLogInterval = 5s;
|
||||||
|
constexpr auto kNotInTestCellLogInterval = 30s;
|
||||||
|
|
||||||
|
enum class ProxyLookupResult
|
||||||
|
{
|
||||||
|
kReferenceEditorId,
|
||||||
|
kBaseActorEditorId,
|
||||||
|
kFallbackFormId
|
||||||
|
};
|
||||||
|
|
||||||
|
RE::ObjectRefHandle g_proxyHandle;
|
||||||
|
bool g_proxyResolvedLogged = false;
|
||||||
|
bool g_proxyMovedLogged = false;
|
||||||
|
auto g_lastMovementTime = std::chrono::steady_clock::time_point{};
|
||||||
|
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
|
||||||
|
|
||||||
|
std::string GetLocalPlayerLogMessage(std::string_view a_message)
|
||||||
|
{
|
||||||
|
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogInfoWithLocalPlayerPrefix(std::string_view a_message)
|
||||||
|
{
|
||||||
|
const auto message = GetLocalPlayerLogMessage(a_message);
|
||||||
|
REX::INFO(std::string_view{ message });
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogWarningWithLocalPlayerPrefix(std::string_view a_message)
|
||||||
|
{
|
||||||
|
const auto message = GetLocalPlayerLogMessage(a_message);
|
||||||
|
REX::WARN(std::string_view{ message });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldLog(std::string a_key, std::chrono::steady_clock::duration a_interval)
|
||||||
|
{
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const auto lastLog = g_lastWarningLogTimes.find(a_key);
|
||||||
|
if (lastLog != g_lastWarningLogTimes.end() && now - lastLog->second < a_interval) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_lastWarningLogTimes[std::move(a_key)] = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogThrottledWarning(
|
||||||
|
const std::string& a_key,
|
||||||
|
std::string_view a_message,
|
||||||
|
std::chrono::steady_clock::duration a_interval = kWarningLogInterval)
|
||||||
|
{
|
||||||
|
if (ShouldLog(a_key, a_interval)) {
|
||||||
|
LogWarningWithLocalPlayerPrefix(a_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogThrottledInfo(
|
||||||
|
const std::string& a_key,
|
||||||
|
std::string_view a_message,
|
||||||
|
std::chrono::steady_clock::duration a_interval = kWarningLogInterval)
|
||||||
|
{
|
||||||
|
if (ShouldLog(a_key, a_interval)) {
|
||||||
|
LogInfoWithLocalPlayerPrefix(a_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId)
|
||||||
|
{
|
||||||
|
const auto* editorId = a_form.GetFormEditorID();
|
||||||
|
return editorId && a_editorId == editorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsTestCell(const RE::TESObjectCELL& a_cell)
|
||||||
|
{
|
||||||
|
const auto* testCell = RE::TESForm::GetFormByEditorID<RE::TESObjectCELL>(RE::BSFixedString(kTestCellEditorId));
|
||||||
|
if (testCell) {
|
||||||
|
return testCell == std::addressof(a_cell) || testCell->GetFormID() == a_cell.GetFormID();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasEditorId(a_cell, kTestCellEditorId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogThrottledWarning(
|
||||||
|
"test_cell_unresolved",
|
||||||
|
"Proxy actor safety test could not resolve F4TTestCell01; actor movement is disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsValidProxyActor(const RE::Actor& a_proxy, const RE::TESObjectCELL& a_expectedCell, const RE::PlayerCharacter& a_player)
|
||||||
|
{
|
||||||
|
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasProxyBaseActorEditorId(const RE::Actor& a_actor)
|
||||||
|
{
|
||||||
|
const auto* baseActor = a_actor.GetNPC();
|
||||||
|
return baseActor && HasEditorId(*baseActor, kProxyBaseActorEditorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::Actor* TryGetCachedProxy(const RE::TESObjectCELL& a_currentCell, const RE::PlayerCharacter& a_player)
|
||||||
|
{
|
||||||
|
if (!g_proxyHandle) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto proxyRef = g_proxyHandle.get();
|
||||||
|
auto* proxy = proxyRef ? proxyRef->As<RE::Actor>() : nullptr;
|
||||||
|
if (proxy && IsValidProxyActor(*proxy, a_currentCell, a_player)) {
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_proxyHandle.reset();
|
||||||
|
g_proxyResolvedLogged = false;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::Actor* TryLookupProxyByEditorId(
|
||||||
|
const RE::TESObjectCELL& a_currentCell,
|
||||||
|
const RE::PlayerCharacter& a_player,
|
||||||
|
ProxyLookupResult& a_lookupResult)
|
||||||
|
{
|
||||||
|
auto* proxy = RE::TESForm::GetFormByEditorID<RE::Actor>(RE::BSFixedString(kProxyEditorId));
|
||||||
|
if (!proxy) {
|
||||||
|
auto* proxyRef = RE::TESForm::GetFormByEditorID<RE::TESObjectREFR>(RE::BSFixedString(kProxyEditorId));
|
||||||
|
proxy = proxyRef ? proxyRef->As<RE::Actor>() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxy || !IsValidProxyActor(*proxy, a_currentCell, a_player)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
a_lookupResult = ProxyLookupResult::kReferenceEditorId;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::Actor* TryFindProxyInCurrentCell(
|
||||||
|
RE::TESObjectCELL& a_currentCell,
|
||||||
|
const RE::PlayerCharacter& a_player,
|
||||||
|
ProxyLookupResult& a_lookupResult)
|
||||||
|
{
|
||||||
|
RE::Actor* proxy = nullptr;
|
||||||
|
|
||||||
|
a_currentCell.ForEachReference([&](RE::TESObjectREFR* a_ref) {
|
||||||
|
if (!a_ref) {
|
||||||
|
return RE::BSContainer::ForEachResult::kContinue;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* actor = a_ref->As<RE::Actor>();
|
||||||
|
if (!actor || !IsValidProxyActor(*actor, a_currentCell, a_player)) {
|
||||||
|
return RE::BSContainer::ForEachResult::kContinue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasEditorId(*a_ref, kProxyEditorId)) {
|
||||||
|
proxy = actor;
|
||||||
|
a_lookupResult = ProxyLookupResult::kReferenceEditorId;
|
||||||
|
return RE::BSContainer::ForEachResult::kStop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasProxyBaseActorEditorId(*actor)) {
|
||||||
|
return RE::BSContainer::ForEachResult::kContinue;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogThrottledInfo("proxy_base_actor_found", std::format(
|
||||||
|
"Found proxy actor by base actor editor ID: {}",
|
||||||
|
kProxyBaseActorEditorId));
|
||||||
|
proxy = actor;
|
||||||
|
a_lookupResult = ProxyLookupResult::kBaseActorEditorId;
|
||||||
|
return RE::BSContainer::ForEachResult::kStop;
|
||||||
|
});
|
||||||
|
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::Actor* TryLookupProxyByFallbackFormId(
|
||||||
|
const RE::TESObjectCELL& a_currentCell,
|
||||||
|
const RE::PlayerCharacter& a_player,
|
||||||
|
ProxyLookupResult& a_lookupResult)
|
||||||
|
{
|
||||||
|
auto* dataHandler = RE::TESDataHandler::GetSingleton();
|
||||||
|
if (!dataHandler) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary safety-test fallback: this local form ID comes from the current
|
||||||
|
// test ESP and may change when Fallout4Together_Test.esp is edited.
|
||||||
|
auto* proxy = dataHandler->LookupForm<RE::Actor>(kProxyFallbackLocalFormId, kTestPluginName);
|
||||||
|
if (!proxy || !IsValidProxyActor(*proxy, a_currentCell, a_player)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
a_lookupResult = ProxyLookupResult::kFallbackFormId;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* GetLookupResultDescription(ProxyLookupResult a_lookupResult)
|
||||||
|
{
|
||||||
|
switch (a_lookupResult) {
|
||||||
|
case ProxyLookupResult::kReferenceEditorId:
|
||||||
|
return "placed reference editor ID";
|
||||||
|
case ProxyLookupResult::kBaseActorEditorId:
|
||||||
|
return "base actor editor ID";
|
||||||
|
case ProxyLookupResult::kFallbackFormId:
|
||||||
|
return "fallback ESP-local form ID";
|
||||||
|
default:
|
||||||
|
return "unknown lookup path";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::Actor* ResolveProxy(RE::TESObjectCELL& a_currentCell, const RE::PlayerCharacter& a_player)
|
||||||
|
{
|
||||||
|
if (auto* cachedProxy = TryGetCachedProxy(a_currentCell, a_player)) {
|
||||||
|
return cachedProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto lookupResult = ProxyLookupResult::kReferenceEditorId;
|
||||||
|
auto* proxy = TryLookupProxyByEditorId(a_currentCell, a_player, lookupResult);
|
||||||
|
if (!proxy) {
|
||||||
|
proxy = TryFindProxyInCurrentCell(a_currentCell, a_player, lookupResult);
|
||||||
|
}
|
||||||
|
if (!proxy) {
|
||||||
|
proxy = TryLookupProxyByFallbackFormId(a_currentCell, a_player, lookupResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxy) {
|
||||||
|
LogThrottledWarning(
|
||||||
|
"proxy_missing",
|
||||||
|
"Proxy actor safety test could not find F4TProxyRemotePlayer01REF in F4TTestCell01.");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_proxyHandle = proxy->GetHandle();
|
||||||
|
if (!g_proxyResolvedLogged) {
|
||||||
|
LogInfoWithLocalPlayerPrefix(std::format(
|
||||||
|
"Proxy actor safety test resolved {} by {} as form {:08X}.",
|
||||||
|
kProxyEditorId,
|
||||||
|
GetLookupResultDescription(lookupResult),
|
||||||
|
proxy->GetFormID()));
|
||||||
|
g_proxyResolvedLogged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldMoveProxy()
|
||||||
|
{
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (g_lastMovementTime.time_since_epoch().count() != 0 && now - g_lastMovementTime < kMovementInterval) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_lastMovementTime = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoveProxyToPlayerOffset(RE::Actor& a_proxy, const RE::PlayerCharacter& a_player)
|
||||||
|
{
|
||||||
|
auto targetPosition = a_player.GetPosition();
|
||||||
|
targetPosition.x += kProxyOffsetX;
|
||||||
|
|
||||||
|
a_proxy.SetPosition(targetPosition, true);
|
||||||
|
a_proxy.SetHeading(a_player.data.angle.z);
|
||||||
|
|
||||||
|
if (!g_proxyMovedLogged) {
|
||||||
|
LogInfoWithLocalPlayerPrefix(std::format(
|
||||||
|
"Proxy actor safety test moved {} to player position plus X offset {:.2f}.",
|
||||||
|
kProxyEditorId,
|
||||||
|
kProxyOffsetX));
|
||||||
|
g_proxyMovedLogged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace F4T::ProxyActorController
|
||||||
|
{
|
||||||
|
void UpdateSafetyTest()
|
||||||
|
{
|
||||||
|
// This temporary safety test intentionally ignores remote-player network state.
|
||||||
|
// Later visual-sync milestones can replace the local-player offset with a
|
||||||
|
// game-thread snapshot of remote transforms.
|
||||||
|
auto* player = RE::PlayerCharacter::GetSingleton();
|
||||||
|
if (!player) {
|
||||||
|
LogThrottledWarning("player_missing", "Proxy actor safety test skipped: local player is not available yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* parentCell = player->GetParentCell();
|
||||||
|
if (!parentCell) {
|
||||||
|
LogThrottledWarning("cell_missing", "Proxy actor safety test skipped: local player parent cell is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTestCell(*parentCell)) {
|
||||||
|
LogThrottledWarning(
|
||||||
|
"not_in_test_cell",
|
||||||
|
"Proxy actor safety test skipped: player is not in F4TTestCell01.",
|
||||||
|
kNotInTestCellLogInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* proxy = ResolveProxy(*parentCell, *player);
|
||||||
|
if (!proxy || !ShouldMoveProxy()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MoveProxyToPlayerOffset(*proxy, *player);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "F4TNetworking.h"
|
#include "F4TNetworking.h"
|
||||||
|
#include "F4TProxyActorController.h"
|
||||||
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -231,6 +232,7 @@ namespace
|
|||||||
// check on the game thread after load, while the logging gate keeps updates readable.
|
// check on the game thread after load, while the logging gate keeps updates readable.
|
||||||
taskInterface->AddTaskPermanent([]() {
|
taskInterface->AddTaskPermanent([]() {
|
||||||
CheckAndLogPlayerPositionChange();
|
CheckAndLogPlayerPositionChange();
|
||||||
|
F4T::ProxyActorController::UpdateSafetyTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
pollingStarted = true;
|
pollingStarted = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user