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
+15 -8
View File
@@ -25,22 +25,26 @@ both send and receive paths:
9. The server broadcasts `disconnect` packets when clients disconnect. 9. The server broadcasts `disconnect` packets when clients disconnect.
10. The plugin and fake client remove disconnected players from their remote 10. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
11. On the game-thread update path, the plugin reads a copied remote-player 11. On the game-thread update path, the proxy controller performs a one-shot
runtime spawn diagnostic in `F4TTestCell01` from the resolved
`F4T_RemotePlayerProxy` actor base. This spawned actor is not yet driven
from remote-player state.
12. On the same game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`. snapshot and moves the single placed proxy actor in `F4TTestCell01`.
Proxy visual updates are independent from local movement log throttling: Proxy visual updates are independent from local movement log throttling:
normal movement is smoothed toward the latest target, while cell changes, normal movement is smoothed toward the latest target, while cell changes,
worldspace changes, and teleports snap directly. worldspace changes, and teleports snap directly.
12. When the selected remote player is missing, disconnected, or not in the same 13. When the selected remote player is missing, disconnected, or not in the same
test cell, the game-thread proxy controller moves the single placed proxy to test cell, the game-thread proxy controller moves the single placed proxy to
a hidden holding position inside `F4TTestCell01` until a valid same-cell a hidden holding position inside `F4TTestCell01` until a valid same-cell
remote player is available again. remote player is available again.
13. As the first runtime proxy actor manager stage, the same game-thread proxy 14. As part of the staged runtime proxy actor manager work, the same game-thread proxy
controller also logs whether `Fallout4Together_Test.esp` is loaded and controller also logs whether `Fallout4Together_Test.esp` is loaded and
whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID. whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID.
The Fallout 4 plugin still does not dynamically spawn remote actors. The actor The Fallout 4 plugin now has a diagnostic-only runtime spawn test, but it still
base lookup is diagnostic-only and keeps the placed proxy reference as the does not dynamically spawn actors for remote player representation. The placed
visual representation path. proxy reference remains the active visual representation path.
## Main Components ## Main Components
@@ -61,11 +65,13 @@ The native plugin is responsible for:
remote player should be represented remote player should be represented
- Logging staged runtime proxy actor base lookup diagnostics on the same - Logging staged runtime proxy actor base lookup diagnostics on the same
game-thread controller path game-thread controller path
- Attempting one game-thread-only runtime spawn diagnostic from
`F4T_RemotePlayerProxy` in `F4TTestCell01`
Planned later: Planned later:
- Runtime spawning one proxy actor from `F4T_RemotePlayerProxy`
- Supporting one proxy actor per represented remote `playerId` - Supporting one proxy actor per represented remote `playerId`
- Driving runtime-spawned proxy actors from remote-player state
### External Server ### External Server
@@ -117,6 +123,7 @@ Networking thread receives packets
Networking thread stores remote player state Networking thread stores remote player state
Game update reads a copied remote player snapshot Game update reads a copied remote player snapshot
Game update resolves/logs proxy actor base diagnostics Game update resolves/logs proxy actor base diagnostics
Game update attempts one diagnostic runtime actor spawn in F4TTestCell01
Game update moves the placed test proxy actor Game update moves the placed test proxy actor
``` ```
@@ -127,7 +134,7 @@ update path.
## Out Of Scope For Current Prototype ## Out Of Scope For Current Prototype
- Runtime actor spawning - Using runtime-spawned actors for remote player representation
- Multiple proxy actors - Multiple proxy actors
- Animation graph sync or animation application - Animation graph sync or animation application
- Combat sync - Combat sync
+69
View File
@@ -1232,6 +1232,75 @@ plugin-local form ID on the existing game-thread proxy controller path.
--- ---
## 2026-06-02 - Runtime Proxy Spawning Stage 2
### Summary
Added a one-shot diagnostic runtime spawn attempt for `F4T_RemotePlayerProxy` in
`F4TTestCell01`. The spawn runs only from the game-thread proxy controller path
and remains separate from the placed proxy fallback, which is still the active
remote player representation.
### Files Changed
- `plugin/src/F4TProxyActorController.cpp`
- `plugin/include/F4TProxyActorController.h`
- `docs/dev-log.md`
- `docs/architecture.md`
### Details
- Added `kEnableRuntimeProxySpawnDiagnostic` as the explicit internal gate for
the Stage 2 diagnostic.
- Added one-shot diagnostic state flags and a separate `RE::ObjectRefHandle` for
the runtime-spawned actor.
- Reused the Stage 1 `F4T_RemotePlayerProxy` actor-base resolution path.
- Calls `RE::TESDataHandler::CreateReferenceAtLocation(RE::NEW_REFR_DATA&)`
only after the local player exists, the parent cell exists, the player is in
`F4TTestCell01`, and the actor base has resolved.
- Fills `NEW_REFR_DATA` for the interior test cell with the proxy actor base as
the bound object, the local player's parent cell as `interior`, no worldspace,
scripts initialized, and an X offset of `250.0`.
- Revalidates the returned handle immediately as a non-player `RE::Actor` in the
expected test cell.
- Logs a single readable attempt and a single success or failure outcome with
actor base, cell, position, handle, spawned reference, actor cast, and parent
cell details where available.
- Explicitly skips the diagnostic spawned reference during placed fallback
base-actor scanning so it is not selected as the active remote proxy.
- Leaves the spawned actor in place for the session; cleanup/despawn/hold
behavior is a later runtime proxy manager milestone.
- Left placed proxy movement, smoothing, special movement snapping, holding
lifecycle, sneak-state observation, networking, protocol, Python server code,
and fake-client tooling unchanged.
### Testing
- Ran `xmake build` from `plugin`; it succeeded.
- Pending: launch Fallout 4 through F4SE, load `Fallout4Together_Test.esp`, and
`coc F4TTestCell01`.
- Pending: confirm `Fallout4Together.log` shows actor-base resolution, one
runtime spawn attempt, and either spawn success details or a clear failure
reason.
- Pending: confirm the new runtime proxy actor appears near the player if spawn
succeeds.
- Pending: confirm the original placed proxy fallback still represents fake
clients and remains the active remote representation.
### Known Issues
- In-game runtime spawn behavior is not yet validated.
- Runtime-spawned actors are not cleaned up, held, or driven from remote-player
state in this stage.
### Next Steps
- Run the build and in-game diagnostic validation.
- If the diagnostic spawn is stable, plan a later stage for safe runtime proxy
lifecycle cleanup/holding before driving spawned proxies from remote state.
---
## Entry Template ## Entry Template
Use this format for future updates: Use this format for future updates:
+1 -1
View File
@@ -2,7 +2,7 @@
namespace F4T::ProxyActorController namespace F4T::ProxyActorController
{ {
// Game-thread-only actor controller for the single placed proxy test actor. // Game-thread-only actor controller for proxy diagnostics and the active placed proxy test actor.
// Networking threads must only update plain remote-player state. // Networking threads must only update plain remote-player state.
void Update(); void Update();
+164 -5
View File
@@ -24,6 +24,8 @@ namespace
constexpr RE::TESFormID kProxyBaseActorLocalFormId = 0x0020A1; constexpr RE::TESFormID kProxyBaseActorLocalFormId = 0x0020A1;
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2; constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F; constexpr auto kProxyOffsetX = 150.0F;
constexpr bool kEnableRuntimeProxySpawnDiagnostic = true;
constexpr auto kRuntimeProxySpawnDiagnosticOffsetX = 250.0F;
constexpr float kProxyPositionLerpAlpha = 0.15F; constexpr float kProxyPositionLerpAlpha = 0.15F;
// Temporary single-proxy lifecycle fallback. Keep the placed proxy inside the // Temporary single-proxy lifecycle fallback. Keep the placed proxy inside the
// loaded test cell instead of disabling a persistent reference before that path // loaded test cell instead of disabling a persistent reference before that path
@@ -67,10 +69,15 @@ namespace
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest; constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
RE::ObjectRefHandle g_proxyHandle; RE::ObjectRefHandle g_proxyHandle;
RE::ObjectRefHandle g_runtimeProxySpawnDiagnosticHandle;
bool g_proxyResolvedLogged = false; bool g_proxyResolvedLogged = false;
bool g_proxyActorBaseDiagnosticStartedLogged = false; bool g_proxyActorBaseDiagnosticStartedLogged = false;
bool g_testPluginLoadedLogged = false; bool g_testPluginLoadedLogged = false;
bool g_proxyActorBaseResolvedLogged = 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_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false; bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false; bool g_remoteStateMovedLogged = false;
@@ -362,7 +369,7 @@ namespace
if (!g_proxyActorBaseResolvedLogged) { if (!g_proxyActorBaseResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format( 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, kProxyBaseActorEditorId,
kTestPluginName, kTestPluginName,
kProxyBaseActorLocalFormId, kProxyBaseActorLocalFormId,
@@ -393,7 +400,7 @@ namespace
if (baseActor && HasEditorId(*baseActor, kProxyBaseActorEditorId) && IsUsableProxyActorBase(*baseActor)) { if (baseActor && HasEditorId(*baseActor, kProxyBaseActorEditorId) && IsUsableProxyActorBase(*baseActor)) {
if (!g_proxyActorBaseResolvedLogged) { if (!g_proxyActorBaseResolvedLogged) {
LogInfoWithLocalPlayerPrefix(std::format( 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, kProxyBaseActorEditorId,
baseActor->GetFormID())); baseActor->GetFormID()));
g_proxyActorBaseResolvedLogged = true; g_proxyActorBaseResolvedLogged = true;
@@ -546,6 +553,153 @@ namespace
return std::addressof(a_proxy) != std::addressof(a_player) && a_proxy.GetParentCell() == std::addressof(a_expectedCell); 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) bool HasProxyBaseActorEditorId(const RE::Actor& a_actor)
{ {
const auto* baseActor = a_actor.GetNPC(); const auto* baseActor = a_actor.GetNPC();
@@ -600,6 +754,10 @@ namespace
return RE::BSContainer::ForEachResult::kContinue; return RE::BSContainer::ForEachResult::kContinue;
} }
if (IsRuntimeProxySpawnDiagnosticRef(*a_ref)) {
return RE::BSContainer::ForEachResult::kContinue;
}
auto* actor = a_ref->As<RE::Actor>(); auto* actor = a_ref->As<RE::Actor>();
if (!actor || !IsValidProxyActor(*actor, a_currentCell, a_player)) { if (!actor || !IsValidProxyActor(*actor, a_currentCell, a_player)) {
return RE::BSContainer::ForEachResult::kContinue; return RE::BSContainer::ForEachResult::kContinue;
@@ -916,9 +1074,10 @@ namespace F4T::ProxyActorController
return; return;
} }
// Stage 1 runtime-spawn diagnostic only. Run before placed-proxy lookup and // Runtime spawn diagnostics run before placed-proxy lookup and before
// before remote-player selection so it is not hidden behind visual fallback state. // remote-player selection so failures are visible even when the fallback works.
(void)ResolveProxyActorBase(); auto* runtimeProxyActorBase = ResolveProxyActorBase();
TrySpawnRuntimeProxyDiagnostic(runtimeProxyActorBase, *parentCell, *player);
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) { if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player); auto* proxy = ResolveProxy(*parentCell, *player);