Add one-shot runtime proxy spawn diagnostic

Introduce a Stage 2 diagnostic that attempts a single runtime spawn of F4T_RemotePlayerProxy in F4TTestCell01 from the game-thread proxy controller. Changes include a new enable flag (kEnableRuntimeProxySpawnDiagnostic), diagnostic offset constant, new diagnostic ObjectRefHandle and boolean state flags, and a TrySpawnRuntimeProxyDiagnostic function that builds NEW_REFR_DATA, calls TESDataHandler::CreateReferenceAtLocation, validates the returned handle/actor/cell, logs a single attempt and success/failure outcome, and intentionally leaves the spawned actor in place. The diagnostic spawn is run before placed-proxy lookup and the spawned reference is skipped during placed proxy scanning so the placed proxy remains the active visual fallback. Updated header comment and documentation (architecture.md and dev-log.md) to describe the new diagnostic behavior and testing notes.
This commit is contained in:
2026-06-02 16:41:37 +12:00
parent df5b0c32cc
commit 9b19143477
4 changed files with 249 additions and 14 deletions
+164 -5
View File
@@ -24,6 +24,8 @@ namespace
constexpr RE::TESFormID kProxyBaseActorLocalFormId = 0x0020A1;
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr bool kEnableRuntimeProxySpawnDiagnostic = true;
constexpr auto kRuntimeProxySpawnDiagnosticOffsetX = 250.0F;
constexpr float kProxyPositionLerpAlpha = 0.15F;
// Temporary single-proxy lifecycle fallback. Keep the placed proxy inside the
// loaded test cell instead of disabling a persistent reference before that path
@@ -67,10 +69,15 @@ namespace
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle;
RE::ObjectRefHandle g_runtimeProxySpawnDiagnosticHandle;
bool g_proxyResolvedLogged = false;
bool g_proxyActorBaseDiagnosticStartedLogged = false;
bool g_testPluginLoadedLogged = false;
bool g_proxyActorBaseResolvedLogged = false;
bool g_runtimeProxySpawnAttempted = false;
bool g_runtimeProxySpawnAttemptInProgress = false;
bool g_runtimeProxySpawnSucceeded = false;
bool g_runtimeProxySpawnFailed = false;
bool g_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false;
@@ -362,7 +369,7 @@ namespace
if (!g_proxyActorBaseResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor base resolved: {} by plugin-local FormID fallback. plugin={}, localFormId={:06X}, runtimeFormId={:08X}, formType={}, isTESNPC=true, editorId={}, displayName={}, accepted=true. Runtime spawning remains disabled for Stage 1.",
"Runtime proxy actor base resolved: {} by plugin-local FormID fallback. plugin={}, localFormId={:06X}, runtimeFormId={:08X}, formType={}, isTESNPC=true, editorId={}, displayName={}, accepted=true.",
kProxyBaseActorEditorId,
kTestPluginName,
kProxyBaseActorLocalFormId,
@@ -393,7 +400,7 @@ namespace
if (baseActor && HasEditorId(*baseActor, kProxyBaseActorEditorId) && IsUsableProxyActorBase(*baseActor)) {
if (!g_proxyActorBaseResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor base resolved: {} by editor ID as form {:08X}. Runtime spawning remains disabled for Stage 1.",
"Runtime proxy actor base resolved: {} by editor ID as form {:08X}.",
kProxyBaseActorEditorId,
baseActor->GetFormID()));
g_proxyActorBaseResolvedLogged = true;
@@ -546,6 +553,153 @@ namespace
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell);
}
bool IsRuntimeProxySpawnDiagnosticRef(const RE::TESObjectREFR& a_ref)
{
if (!g_runtimeProxySpawnDiagnosticHandle) {
return false;
}
const auto spawnedRef = g_runtimeProxySpawnDiagnosticHandle.get();
return spawnedRef && spawnedRef.get() == std::addressof(a_ref);
}
void MarkRuntimeProxySpawnDiagnosticFailed(std::string_view a_reason)
{
g_runtimeProxySpawnFailed = true;
g_runtimeProxySpawnAttemptInProgress = false;
LogWarningWithLocalPlayerPrefix(std::format(
"Runtime proxy spawn diagnostic failed: {}",
a_reason));
}
void TrySpawnRuntimeProxyDiagnostic(
RE::TESNPC* a_baseActor,
RE::TESObjectCELL& a_parentCell,
const RE::PlayerCharacter& a_player)
{
if constexpr (!kEnableRuntimeProxySpawnDiagnostic) {
return;
}
if (g_runtimeProxySpawnAttempted ||
g_runtimeProxySpawnSucceeded ||
g_runtimeProxySpawnAttemptInProgress ||
g_runtimeProxySpawnFailed) {
return;
}
if (!a_baseActor) {
return;
}
auto* dataHandler = RE::TESDataHandler::GetSingleton();
if (!dataHandler) {
g_runtimeProxySpawnAttempted = true;
MarkRuntimeProxySpawnDiagnosticFailed("TESDataHandler is unavailable.");
return;
}
auto* boundObject = a_baseActor->As<RE::TESBoundObject>();
if (!boundObject) {
g_runtimeProxySpawnAttempted = true;
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"actor base {:08X} is not usable as NEW_REFR_DATA::object.",
a_baseActor->GetFormID()));
return;
}
g_runtimeProxySpawnAttempted = true;
g_runtimeProxySpawnAttemptInProgress = true;
auto spawnPosition = a_player.GetPosition();
spawnPosition.x += kRuntimeProxySpawnDiagnosticOffsetX;
RE::NiPoint3 spawnDirection{};
spawnDirection.z = a_player.data.angle.z;
RE::NEW_REFR_DATA spawnData;
spawnData.object = boundObject;
spawnData.location = spawnPosition;
spawnData.direction = spawnDirection;
spawnData.interior = std::addressof(a_parentCell);
spawnData.world = nullptr;
spawnData.reference = nullptr;
spawnData.forcePersist = false;
spawnData.initializeScripts = true;
spawnData.initiallyDisabled = false;
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy spawn diagnostic attempting: actorBase={:08X}, cell={:08X}, position=({:.2f}, {:.2f}, {:.2f}), heading={:.2f}.",
a_baseActor->GetFormID(),
a_parentCell.GetFormID(),
spawnPosition.x,
spawnPosition.y,
spawnPosition.z,
spawnDirection.z));
g_runtimeProxySpawnDiagnosticHandle = dataHandler->CreateReferenceAtLocation(spawnData);
if (!g_runtimeProxySpawnDiagnosticHandle) {
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"CreateReferenceAtLocation returned an invalid handle for actorBase={:08X}, cell={:08X}, position=({:.2f}, {:.2f}, {:.2f}).",
a_baseActor->GetFormID(),
a_parentCell.GetFormID(),
spawnPosition.x,
spawnPosition.y,
spawnPosition.z));
return;
}
const auto spawnedRef = g_runtimeProxySpawnDiagnosticHandle.get();
if (!spawnedRef) {
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"created handle {:08X} could not resolve to a reference.",
g_runtimeProxySpawnDiagnosticHandle.get_handle()));
return;
}
auto* spawnedActor = spawnedRef->As<RE::Actor>();
if (!spawnedActor) {
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"created reference {:08X} resolved but was not an actor.",
spawnedRef->GetFormID()));
return;
}
if (std::addressof(*spawnedActor) == std::addressof(a_player)) {
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"created reference {:08X} unexpectedly resolved as the local player.",
spawnedRef->GetFormID()));
return;
}
const auto* spawnedCell = spawnedActor->GetParentCell();
if (spawnedCell != std::addressof(a_parentCell)) {
MarkRuntimeProxySpawnDiagnosticFailed(std::format(
"created actor {:08X} parent cell validation failed: expected={:08X}, actual={}.",
spawnedRef->GetFormID(),
a_parentCell.GetFormID(),
spawnedCell ? std::format("{:08X}", spawnedCell->GetFormID()) : "unavailable"));
return;
}
spawnedActor->SetPosition(spawnPosition, true);
spawnedActor->SetHeading(spawnDirection.z);
// TODO: Add safe cleanup/despawn/hold behavior once runtime proxy lifecycle
// ownership is designed. Stage 2 intentionally leaves the actor in place.
g_runtimeProxySpawnSucceeded = true;
g_runtimeProxySpawnAttemptInProgress = false;
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy spawn diagnostic succeeded: actorBase={:08X}, handle={:08X}, createdRef={:08X}, resolvedAsActor=true, parentCell={:08X}, position=({:.2f}, {:.2f}, {:.2f}). The placed proxy fallback remains the active remote representation. TODO: add safe runtime proxy cleanup/despawn/hold behavior in a later stage.",
a_baseActor->GetFormID(),
g_runtimeProxySpawnDiagnosticHandle.get_handle(),
spawnedRef->GetFormID(),
spawnedCell->GetFormID(),
spawnPosition.x,
spawnPosition.y,
spawnPosition.z));
}
bool HasProxyBaseActorEditorId(const RE::Actor& a_actor)
{
const auto* baseActor = a_actor.GetNPC();
@@ -600,6 +754,10 @@ namespace
return RE::BSContainer::ForEachResult::kContinue;
}
if (IsRuntimeProxySpawnDiagnosticRef(*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;
@@ -916,9 +1074,10 @@ namespace F4T::ProxyActorController
return;
}
// Stage 1 runtime-spawn diagnostic only. Run before placed-proxy lookup and
// before remote-player selection so it is not hidden behind visual fallback state.
(void)ResolveProxyActorBase();
// Runtime spawn diagnostics run before placed-proxy lookup and before
// remote-player selection so failures are visible even when the fallback works.
auto* runtimeProxyActorBase = ResolveProxyActorBase();
TrySpawnRuntimeProxyDiagnostic(runtimeProxyActorBase, *parentCell, *player);
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player);