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:
@@ -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 "F4TProxyActorController.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
@@ -231,6 +232,7 @@ namespace
|
||||
// check on the game thread after load, while the logging gate keeps updates readable.
|
||||
taskInterface->AddTaskPermanent([]() {
|
||||
CheckAndLogPlayerPositionChange();
|
||||
F4T::ProxyActorController::UpdateSafetyTest();
|
||||
});
|
||||
|
||||
pollingStarted = true;
|
||||
|
||||
Reference in New Issue
Block a user