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:
2026-05-31 21:57:32 +12:00
parent c5fdcf2dfb
commit 49819813da
4 changed files with 372 additions and 0 deletions
+35
View File
@@ -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.
+8
View File
@@ -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();
}
+327
View File
@@ -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);
}
}
+2
View File
@@ -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;