Add data-only proxy animation debug logging

Track and log per-slot animation/movement diagnostics for runtime proxy actors without applying any visual animation. Adds a ProxyAnimationSpeedBucket enum and GetProxyAnimationSpeedBucket/GetProxyAnimationSpeedBucketLabel helpers, a new UpdateProxyAnimationStateDebug() routine, and fields on ProxyActorSlot to remember last-observed moving, sprinting, sneaking, jumping, weapon-drawn, movement type, and coarse speed-bucket state. Resets observation state when slots are created/reused and emits initial/transition-only logs including playerId and proxy FormID. Documentation and dev-log entries updated; visual animation, animation graph changes, and AI/movement behavior remain intentionally unmodified.
This commit is contained in:
2026-06-03 11:36:38 +12:00
parent 3d1abc66ad
commit 44ea1b98da
3 changed files with 189 additions and 19 deletions
+17 -3
View File
@@ -32,7 +32,7 @@ both send and receive paths:
12. For each eligible remote `playerId`, the controller maintains a 12. For each eligible remote `playerId`, the controller maintains a
`remotePlayerId -> runtime proxy slot` mapping. Each slot stores its `remotePlayerId -> runtime proxy slot` mapping. Each slot stores its
`ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state, `ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state,
movement smoothing state, and sneak observation state. Stage 4 caps runtime movement smoothing state, and data-only animation debug state. Stage 4 caps runtime
proxy slots at four actors for testing. proxy slots at four actors for testing.
13. Runtime proxies are demand-driven. A slot executes the Stage 3.7 13. Runtime proxies are demand-driven. A slot executes the Stage 3.7
PlaceAtMe-backed spawn flow only when that remote player needs a same-cell PlaceAtMe-backed spawn flow only when that remote player needs a same-cell
@@ -53,8 +53,9 @@ both send and receive paths:
actor. actor.
15. Proxy visual updates are independent per slot. Normal movement is smoothed 15. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that players latest target, while `cell_change`, `worldspace_change`, toward that players latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Sneak state is still observed and logged only; and `teleport` snap directly. Movement and animation-related remote state is
no visual crouch/sneak application is attempted. still observed and logged only; no visual crouch/sneak, jump, weapon drawn,
locomotion, animation graph, or actor-state application is attempted.
16. When a remote player leaves the cell or disconnects, only that players 16. When a remote player leaves the cell or disconnects, only that players
runtime proxy is moved to the hidden holding position inside runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected `F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
@@ -118,6 +119,15 @@ reused.
The placed proxy reference remains as a single fallback path if runtime spawning The placed proxy reference remains as a single fallback path if runtime spawning
fails for one selected remote player. fails for one selected remote player.
Current animation-state handling is data/debug only:
```mermaid
flowchart LR
RemotePlayerState[RemotePlayerState] --> ProxyActorSlot[ProxyActorSlot animation debug state]
ProxyActorSlot --> Logs[Initial and transition logs]
ProxyActorSlot --> FutureVisualAnimation[Future visual animation application]
```
## Main Components ## Main Components
### F4SE Plugin ### F4SE Plugin
@@ -153,6 +163,9 @@ The native plugin is responsible for:
path after spawn promotion, during reusable-slot reassignment, periodically path after spawn promotion, during reusable-slot reassignment, periodically
while active, and while held so they remain passive visual puppets rather than while active, and while held so they remain passive visual puppets rather than
independent combat actors independent combat actors
- Logging initial and transition-only data/debug animation state per resolved
runtime proxy slot from `RemotePlayerState`, including moving, speed bucket,
sprinting, sneaking, jumping, weapon drawn, and movement type
- Suppressing exposed AI movement intent for runtime proxies by clearing safe - Suppressing exposed AI movement intent for runtime proxies by clearing safe
process target handles and using validated package interruption/do-nothing process target handles and using validated package interruption/do-nothing
calls when combat, pathing, flee/alarm, bump, or package movement state is calls when combat, pathing, flee/alarm, bump, or package movement state is
@@ -237,6 +250,7 @@ Game update attempts at most one PlaceAtMe-backed runtime actor spawn at a time
Game update snapshots matching candidates, isolates the new actor, and waits for the settle window Game update snapshots matching candidates, isolates the new actor, and waits for the settle window
Game update moves isolated Stage 4 runtime spawn candidates to the remote player's current transform Game update moves isolated Stage 4 runtime spawn candidates to the remote player's current transform
Game update stores promoted runtime actors as ObjectRefHandle values in slots Game update stores promoted runtime actors as ObjectRefHandle values in slots
Game update logs data-only animation debug state for resolved runtime proxy slots
Game update neutralizes resolved runtime proxy actors after spawn promotion, reuse, active throttled checks, and holding Game update neutralizes resolved runtime proxy actors after spawn promotion, reuse, active throttled checks, and holding
Game update suppresses exposed AI movement/pathing intent without forcing animation graph events Game update suppresses exposed AI movement/pathing intent without forcing animation graph events
Game update moves each valid proxy from its assigned remote player state Game update moves each valid proxy from its assigned remote player state
+57
View File
@@ -2257,6 +2257,63 @@ starting near the local player before snapping away.
--- ---
## 2026-06-03 - Proxy Animation State Debug Pass
### Summary
Added data-only per-slot animation state diagnostics for runtime proxy actors so
the proxy controller can observe movement and animation-related state assigned to
each remote `playerId` without visually applying animation.
### Files Changed
- `plugin/src/F4TProxyActorController.cpp`
- `docs/architecture.md`
- `docs/dev-log.md`
### Details
- Added per-runtime-proxy-slot debug tracking for moving, speed bucket,
sprinting, sneaking, jumping, weapon drawn, and movement type.
- Added transition-only runtime proxy animation state logs that include the
remote `playerId` and resolved proxy actor FormID.
- Movement speed diagnostics use coarse buckets (`idle`, `slow`, `walk`, `run`,
`sprint`) so small float changes do not spam the log.
- Reset animation debug state when slots are created, reassigned, held, or pass
through the existing slot observation reset helper.
- Kept this pass data/debug only. It does not apply visual animation, force
animation graph events, write animation graph variables, call `PerformAction`,
write `ActorState`, or add package/pathing/AI movement changes.
- Networking, protocol, Python server code, fake-client tooling, placed fallback
behavior, spawn-at-remote-position, movement smoothing, snap behavior,
same-cell gating, held/reusable lifecycle, and combat/AI neutralization were
unchanged.
### Testing
- Ran `xmake build` from `plugin`; it succeeded and rebuilt
`Fallout4Together.dll`.
- Pending manual validation: launch through F4SE, start the server, add fake
clients in `F4TTestCell01`, and confirm per-player initial and transition
animation state logs without visual animation changes.
### Known Issues
- Speed bucket thresholds are conservative diagnostics and still need tuning
against real Fallout 4 movement units before any future visual locomotion work.
- Visual animation application remains intentionally unimplemented.
### Next Steps
- Investigate safe visual sneak/crouch application.
- Investigate weapon drawn visual application.
- Investigate locomotion animation graph behavior.
- Investigate jump animation behavior.
- Research safe animation graph variable/event reads or writes before any visual
animation milestone.
---
## Entry Template ## Entry Template
Use this format for future updates: Use this format for future updates:
+115 -16
View File
@@ -141,6 +141,15 @@ namespace
kHolding kHolding
}; };
enum class ProxyAnimationSpeedBucket
{
kIdle,
kSlow,
kWalk,
kRun,
kSprint
};
struct RemotePlayerSelection struct RemotePlayerSelection
{ {
std::optional<RemotePlayer> selectedPlayer; std::optional<RemotePlayer> selectedPlayer;
@@ -218,7 +227,14 @@ namespace
bool remoteStateMovedLogged{ false }; bool remoteStateMovedLogged{ false };
bool reusableLogged{ false }; bool reusableLogged{ false };
bool directRemoteSpawnLogged{ false }; bool directRemoteSpawnLogged{ false };
std::optional<bool> lastObservedSneakState; bool hasLoggedInitialAnimationState{ false };
std::optional<bool> lastObservedMovingState;
std::optional<bool> lastObservedSprintingState;
std::optional<bool> lastObservedSneakingState;
std::optional<bool> lastObservedJumpingState;
std::optional<bool> lastObservedWeaponDrawnState;
std::optional<std::string> lastObservedMovementType;
std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket;
}; };
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest; constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
@@ -361,6 +377,10 @@ namespace
} }
void ResetRemoteMovementStateTracking(); void ResetRemoteMovementStateTracking();
void UpdateProxyAnimationStateDebug(
ProxyActorSlot& a_slot,
const RemotePlayer& a_remotePlayer,
const RE::Actor& a_proxy);
const char* GetProxyNeutralizationReasonLabel(ProxyNeutralizationReason a_reason) const char* GetProxyNeutralizationReasonLabel(ProxyNeutralizationReason a_reason)
{ {
@@ -378,6 +398,47 @@ namespace
} }
} }
const char* GetProxyAnimationSpeedBucketLabel(ProxyAnimationSpeedBucket a_bucket)
{
switch (a_bucket) {
case ProxyAnimationSpeedBucket::kIdle:
return "idle";
case ProxyAnimationSpeedBucket::kSlow:
return "slow";
case ProxyAnimationSpeedBucket::kWalk:
return "walk";
case ProxyAnimationSpeedBucket::kRun:
return "run";
case ProxyAnimationSpeedBucket::kSprint:
return "sprint";
default:
return "unknown";
}
}
ProxyAnimationSpeedBucket GetProxyAnimationSpeedBucket(const RemotePlayer& a_remotePlayer)
{
if (!a_remotePlayer.isMoving || a_remotePlayer.movementSpeed < 1.0F) {
return ProxyAnimationSpeedBucket::kIdle;
}
if (a_remotePlayer.isSprinting) {
return ProxyAnimationSpeedBucket::kSprint;
}
// TODO: Tune these diagnostic thresholds against real Fallout 4 movement units
// before using them for visual locomotion animation decisions.
if (a_remotePlayer.movementSpeed < 75.0F) {
return ProxyAnimationSpeedBucket::kSlow;
}
if (a_remotePlayer.movementSpeed < 150.0F) {
return ProxyAnimationSpeedBucket::kWalk;
}
if (a_remotePlayer.movementSpeed < 300.0F) {
return ProxyAnimationSpeedBucket::kRun;
}
return ProxyAnimationSpeedBucket::kSprint;
}
std::string FormatPosition(const RE::NiPoint3& a_position) std::string FormatPosition(const RE::NiPoint3& a_position)
{ {
return std::format("({:.2f}, {:.2f}, {:.2f})", a_position.x, a_position.y, a_position.z); return std::format("({:.2f}, {:.2f}, {:.2f})", a_position.x, a_position.y, a_position.z);
@@ -4001,7 +4062,14 @@ namespace
void ResetSlotObservationState(ProxyActorSlot& a_slot) void ResetSlotObservationState(ProxyActorSlot& a_slot)
{ {
a_slot.lastObservedSneakState.reset(); a_slot.hasLoggedInitialAnimationState = false;
a_slot.lastObservedMovingState.reset();
a_slot.lastObservedSprintingState.reset();
a_slot.lastObservedSneakingState.reset();
a_slot.lastObservedJumpingState.reset();
a_slot.lastObservedWeaponDrawnState.reset();
a_slot.lastObservedMovementType.reset();
a_slot.lastObservedMovementSpeedBucket.reset();
} }
bool HasSlotHeldSinceTime(const ProxyActorSlot& a_slot) bool HasSlotHeldSinceTime(const ProxyActorSlot& a_slot)
@@ -4161,7 +4229,6 @@ namespace
a_slot.restoredForCurrentState = true; a_slot.restoredForCurrentState = true;
a_slot.firstSmoothedMovementLogged = false; a_slot.firstSmoothedMovementLogged = false;
a_slot.remoteStateMovedLogged = false; a_slot.remoteStateMovedLogged = false;
a_slot.lastObservedSneakState = a_remotePlayer.isSneaking;
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy actor {:08X} restored/reused for remote player {} at X={:.2f}, Y={:.2f}, Z={:.2f}.", "Runtime proxy actor {:08X} restored/reused for remote player {} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
@@ -4170,6 +4237,7 @@ namespace
a_remotePlayer.x, a_remotePlayer.x,
a_remotePlayer.y, a_remotePlayer.y,
a_remotePlayer.z)); a_remotePlayer.z));
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
LogProxyMovementDiagnostic( LogProxyMovementDiagnostic(
a_proxy, a_proxy,
a_remotePlayer, a_remotePlayer,
@@ -4179,22 +4247,52 @@ namespace
ProxyMovementDiagnosticMode::kRestore); ProxyMovementDiagnosticMode::kRestore);
} }
void ApplyRemoteMovementStateToSlot(ProxyActorSlot& a_slot, const RemotePlayer& a_remotePlayer) void UpdateProxyAnimationStateDebug(
ProxyActorSlot& a_slot,
const RemotePlayer& a_remotePlayer,
const RE::Actor& a_proxy)
{ {
if (!a_slot.lastObservedSneakState) { const auto speedBucket = GetProxyAnimationSpeedBucket(a_remotePlayer);
a_slot.lastObservedSneakState = a_remotePlayer.isSneaking; const auto shouldLogInitial = !a_slot.hasLoggedInitialAnimationState;
return; const auto changed =
} !a_slot.lastObservedMovingState || *a_slot.lastObservedMovingState != a_remotePlayer.isMoving ||
!a_slot.lastObservedSprintingState || *a_slot.lastObservedSprintingState != a_remotePlayer.isSprinting ||
!a_slot.lastObservedSneakingState || *a_slot.lastObservedSneakingState != a_remotePlayer.isSneaking ||
!a_slot.lastObservedJumpingState || *a_slot.lastObservedJumpingState != a_remotePlayer.isJumping ||
!a_slot.lastObservedWeaponDrawnState || *a_slot.lastObservedWeaponDrawnState != a_remotePlayer.weaponDrawn ||
!a_slot.lastObservedMovementType || *a_slot.lastObservedMovementType != a_remotePlayer.movementType ||
!a_slot.lastObservedMovementSpeedBucket || *a_slot.lastObservedMovementSpeedBucket != speedBucket;
if (*a_slot.lastObservedSneakState == a_remotePlayer.isSneaking) { if (shouldLogInitial || changed) {
return;
}
a_slot.lastObservedSneakState = a_remotePlayer.isSneaking;
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Remote player {} sneak state changed: isSneaking={}", "Runtime proxy animation state {} for remote player {}: actor={:08X}, holdingIndex={}, moving={}, speed={:.1f}, speedBucket={}, sprinting={}, sneaking={}, jumping={}, weaponDrawn={}, movementType={}.",
shouldLogInitial ? "initial" : "changed",
a_remotePlayer.playerId, a_remotePlayer.playerId,
a_remotePlayer.isSneaking)); a_proxy.GetFormID(),
a_slot.holdingPositionIndex,
a_remotePlayer.isMoving,
a_remotePlayer.movementSpeed,
GetProxyAnimationSpeedBucketLabel(speedBucket),
a_remotePlayer.isSprinting,
a_remotePlayer.isSneaking,
a_remotePlayer.isJumping,
a_remotePlayer.weaponDrawn,
a_remotePlayer.movementType));
}
a_slot.hasLoggedInitialAnimationState = true;
a_slot.lastObservedMovingState = a_remotePlayer.isMoving;
a_slot.lastObservedSprintingState = a_remotePlayer.isSprinting;
a_slot.lastObservedSneakingState = a_remotePlayer.isSneaking;
a_slot.lastObservedJumpingState = a_remotePlayer.isJumping;
a_slot.lastObservedWeaponDrawnState = a_remotePlayer.weaponDrawn;
a_slot.lastObservedMovementType = a_remotePlayer.movementType;
a_slot.lastObservedMovementSpeedBucket = speedBucket;
// TODO: Future visual milestones should investigate safe sneak/crouch
// application, weapon drawn visuals, locomotion graph behavior, jump
// animation behavior, and read-only research for animation graph
// variables/events before any visual animation state is applied.
} }
void MoveSlotProxyToRemotePlayer( void MoveSlotProxyToRemotePlayer(
@@ -4218,6 +4316,7 @@ namespace
a_slot.holdReason.reset(); a_slot.holdReason.reset();
a_slot.heldSinceTime = {}; a_slot.heldSinceTime = {};
a_slot.reusableLogged = false; a_slot.reusableLogged = false;
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
if (shouldSnap) { if (shouldSnap) {
LogThrottledInfo( LogThrottledInfo(
@@ -4362,6 +4461,7 @@ namespace
CaptureSlotInitialRemoteSpawnTransform(slot->second, a_remotePlayer); CaptureSlotInitialRemoteSpawnTransform(slot->second, a_remotePlayer);
if (inserted) { if (inserted) {
slot->second.holdingPositionIndex = g_nextProxyHoldingPositionIndex++; slot->second.holdingPositionIndex = g_nextProxyHoldingPositionIndex++;
ResetSlotObservationState(slot->second);
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy slot created for remote player {} with holding index {}.", "Runtime proxy slot created for remote player {} with holding index {}.",
a_remotePlayer.playerId, a_remotePlayer.playerId,
@@ -4416,7 +4516,6 @@ namespace
return true; return true;
} }
ApplyRemoteMovementStateToSlot(a_slot, a_remotePlayer);
MoveSlotProxyToRemotePlayer(a_slot, *proxy, a_remotePlayer, a_player, a_sameCellPassed); MoveSlotProxyToRemotePlayer(a_slot, *proxy, a_remotePlayer, a_player, a_sameCellPassed);
return true; return true;
} }