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:
+15
-8
@@ -25,22 +25,26 @@ both send and receive paths:
|
||||
9. The server broadcasts `disconnect` packets when clients disconnect.
|
||||
10. The plugin and fake client remove disconnected players from their remote
|
||||
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`.
|
||||
Proxy visual updates are independent from local movement log throttling:
|
||||
normal movement is smoothed toward the latest target, while cell changes,
|
||||
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
|
||||
a hidden holding position inside `F4TTestCell01` until a valid same-cell
|
||||
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
|
||||
whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID.
|
||||
|
||||
The Fallout 4 plugin still does not dynamically spawn remote actors. The actor
|
||||
base lookup is diagnostic-only and keeps the placed proxy reference as the
|
||||
visual representation path.
|
||||
The Fallout 4 plugin now has a diagnostic-only runtime spawn test, but it still
|
||||
does not dynamically spawn actors for remote player representation. The placed
|
||||
proxy reference remains the active visual representation path.
|
||||
|
||||
## Main Components
|
||||
|
||||
@@ -61,11 +65,13 @@ The native plugin is responsible for:
|
||||
remote player should be represented
|
||||
- Logging staged runtime proxy actor base lookup diagnostics on the same
|
||||
game-thread controller path
|
||||
- Attempting one game-thread-only runtime spawn diagnostic from
|
||||
`F4T_RemotePlayerProxy` in `F4TTestCell01`
|
||||
|
||||
Planned later:
|
||||
|
||||
- Runtime spawning one proxy actor from `F4T_RemotePlayerProxy`
|
||||
- Supporting one proxy actor per represented remote `playerId`
|
||||
- Driving runtime-spawned proxy actors from remote-player state
|
||||
|
||||
### External Server
|
||||
|
||||
@@ -117,6 +123,7 @@ Networking thread receives packets
|
||||
Networking thread stores remote player state
|
||||
Game update reads a copied remote player snapshot
|
||||
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
|
||||
```
|
||||
|
||||
@@ -127,7 +134,7 @@ update path.
|
||||
|
||||
## Out Of Scope For Current Prototype
|
||||
|
||||
- Runtime actor spawning
|
||||
- Using runtime-spawned actors for remote player representation
|
||||
- Multiple proxy actors
|
||||
- Animation graph sync or animation application
|
||||
- Combat sync
|
||||
|
||||
@@ -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
|
||||
|
||||
Use this format for future updates:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
void Update();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user