Add proxy puppet hook and graph speed sync
Introduce a puppet vtable hook to preserve networked locomotion Speed and fix proxy cadence. Added F4TProxyPuppet (header + impl) which installs an Actor::Update hook that writes the desired graph Speed and calls UpdateNoAI for proxy actors so the engine does not recompute/zero Speed from controller velocity. Publish active proxy FormIDs and per-proxy desired speeds so the hook targets only proxy actors. Wire animationGraphSpeed through networking: parse/send a new "animationGraphSpeed" field and store it on RemotePlayerState. Proxy animation sync now prefers the authoritative animationGraphSpeed when present, otherwise maps movementSpeed ~1:1 to graph Speed (with sprint floor and clamping). Added graph variables speedSampled/speedDamped alongside Speed and updated snapshot population and diagnostics. Removed character-controller velocity injection (was causing physics/animation conflicts) and adjusted proxy movement logic to rely on graph variables plus kinematic SetPosition. Also: Publish puppet diagnostics from the controller, add sender-side local graph speed read diagnostic, update fake_player to compute and send animationGraphSpeed, and append detailed dev-log entries describing the root cause, diagnostics, and fixes. Build and runtime diagnostics added to verify hook behavior and cadence changes.
This commit is contained in:
+170
-1
@@ -4398,4 +4398,173 @@ The crash correlated with disabling `g_preserveProxyAIForAnimationTesting` and r
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **SUCCESS** - Rollback build succeeded and the DLL was installed to the F4SE folder.
|
||||
✅ **SUCCESS** - Rollback build succeeded and the DLL was installed to the F4SE folder.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-04 - Proxy Locomotion Cadence: Engine Speed Clobber Root Cause + Puppet Hook
|
||||
|
||||
### Symptom
|
||||
|
||||
Remote proxy actors always played a single fixed "medium walk" cadence regardless
|
||||
of the sender's real speed (slow walk, normal walk, run all looked the same).
|
||||
Neither the earlier 0-105 bucket mapping nor the corrected ~1:1
|
||||
`movementSpeed -> graph Speed` mapping changed the cadence at all.
|
||||
|
||||
### Root Cause (confirmed)
|
||||
|
||||
Fallout 4 recomputes the locomotion graph `Speed`/`Direction` every frame from the
|
||||
actor's **actual character-controller velocity**, via the actor virtual
|
||||
`UpdateFeedbackGraphSpeedAndDirection` (CommonLibF4 Actor vtable index `0x129`,
|
||||
fed by `ComputeMotionFeedbackSpeedAndDirection` at `0x128`).
|
||||
|
||||
Proxies move via `SetPosition` (kinematic teleport), so their controller velocity
|
||||
is ~0. With the proxy's AI/process left active (`g_preserveProxyAIForAnimationTesting`),
|
||||
the engine writes Speed≈0 into the graph every frame, **overwriting** whatever
|
||||
networked Speed we set. The `isMoving` bool still forces a walk state, so the graph
|
||||
plays the slowest in-state walk cadence — the observed fixed "medium walk".
|
||||
|
||||
This is the same architectural limitation noted earlier, now pinned to the exact
|
||||
engine routine responsible for the clobber.
|
||||
|
||||
### Why TiltedEvolution Doesn't Have This
|
||||
|
||||
TiltedEvolution remotes are **puppets**: it hooks `Actor::Process(float)` and returns
|
||||
early for remote actors (`Code/client/Games/Skyrim/Actor.cpp` `HookActorProcess`),
|
||||
so the engine never recomputes their locomotion. The proxy is driven entirely by
|
||||
replayed graph variables (`LoadAnimationVariables`, raw behavior-graph memory writes)
|
||||
+ `SetPosition` each interpolation tick. Note their locomotion variable is
|
||||
`SpeedSampled` (see `Actor::GetSpeed`/`SetSpeed`).
|
||||
|
||||
### Fix (this change)
|
||||
|
||||
Surgically scoped puppet hook instead of disabling the whole AI update (the broad
|
||||
neutralization path previously crashed):
|
||||
|
||||
- New module `plugin/src/F4TProxyPuppet.cpp` + `plugin/include/F4TProxyPuppet.h`.
|
||||
- Hooks `RE::Actor::VTABLE[0]` index `0x129` (`UpdateFeedbackGraphSpeedAndDirection`).
|
||||
For actors that back active proxy slots, the hook **skips** the engine call (returns
|
||||
`false`) so our networked Speed/Direction persists; all real NPCs fall through to
|
||||
the original implementation unchanged. The local player is a `PlayerCharacter`
|
||||
(separate vtable) and is never affected.
|
||||
- `F4TProxyActorController::Update()` publishes the current proxy actor FormIDs each
|
||||
tick via `ProxyPuppet::SetActiveProxyFormIDs()`; the hook gates on
|
||||
`ProxyPuppet::IsProxyFormID()` (shared_mutex-guarded set).
|
||||
- Hook installed once from `StartPlayerPositionPolling()` (after game load, so REL
|
||||
addresses resolve). No trampoline needed for a vtable swap.
|
||||
|
||||
With the engine no longer clobbering Speed, the existing per-frame graph-variable
|
||||
writes (now using the ~1:1 `movementSpeed -> Speed` mapping, range ~0-373 confirmed
|
||||
from local-player debug) should finally vary the proxy walk/run cadence.
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **SUCCESS** - `xmake build` clean (only pre-existing C4702/C4100 warnings).
|
||||
|
||||
### Next Step / To Verify In-Game
|
||||
|
||||
- Confirm slow vs normal vs run now produce visibly different proxy cadence.
|
||||
- Confirm proxies still animate idle correctly when stopped (Speed -> idle).
|
||||
- Watch for any side effects of skipping feedback Speed/Direction (e.g. strafing
|
||||
direction, foot IK). Direction is no longer engine-driven for proxies; forward
|
||||
locomotion assumed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-04 - Proxy Cadence: Diagnostics Disprove 0x129 Hook; Move to Actor::Update Re-assert
|
||||
|
||||
### Decisive Diagnostic Run (fake-client Walk Circle, single instance)
|
||||
|
||||
Added per-update proxy diagnostics + hook call counters and drove a moving proxy
|
||||
via the dev server's fake client (movementSpeed=180) so a single instance both
|
||||
owns the log and shows a moving proxy. Result (`Proxy anim diag p2 ...`):
|
||||
|
||||
- `recvMoving=true, recvMovementSpeed=180, recvAnimGraphSpeed=180, computedGraphSpeed=180`
|
||||
→ the receive + compute path is correct; the proxy gets a good speed value.
|
||||
- `graphSpeedReadBack=true(0.0)` every frame → our written graph "Speed" is reset
|
||||
to 0 before the next read. The write does NOT persist. This is the real reason
|
||||
the cadence never varies: the walk plays from `isMoving`, but at Speed 0 it's
|
||||
the slowest in-state blend, always.
|
||||
- `feedbackHookTotalCalls=0` even with the proxy moving → the previously hooked
|
||||
`UpdateFeedbackGraphSpeedAndDirection` (vtable 0x129) is NEVER dispatched through
|
||||
the vtable. The engine calls it by direct address, so the vtable hook was inert.
|
||||
- `speedSampledReadBack=false` → "speedSampled" is not a real FO4 graph variable
|
||||
(only "Speed" exists / reads back). Correct FO4 name is "SpeedSampled".
|
||||
|
||||
Also from sender-side diag: local graph Speed scale is ~57 (slow walk) up to ~500
|
||||
(sprint), confirming the ~1:1-with-clamp mapping is roughly right, and that the
|
||||
correct path is the transmitted `animationGraphSpeed`, not the noisy
|
||||
position-delta `movementSpeed` (which ranged 811-6650).
|
||||
|
||||
### Fix Pivot
|
||||
|
||||
- Re-targeted the hook from 0x129 to `Actor::Update(float)` (vtable 0xCF), which
|
||||
IS dispatched virtually (the FO4 analog of TiltedEvolution's `Actor::Process`).
|
||||
- Strategy changed from "skip" to "re-assert": the hook calls the original
|
||||
`Update` (so the actor still renders/animates/positions; no freeze/crash risk),
|
||||
then for proxy FormIDs immediately re-writes graph "Speed"/"SpeedSampled" to the
|
||||
networked value via `SetProxyDesiredSpeed` published by the animation sync.
|
||||
- Idle proxies publish speed = -1 (no override) so the engine's idle Speed (0)
|
||||
stands.
|
||||
|
||||
### Open Risk
|
||||
|
||||
If FO4 ticks/generates the animation pose INSIDE `Actor::Update` (before our
|
||||
re-assert), the post-Update write will be too late and cadence still won't change.
|
||||
If pose generation runs in a later/separate animation pass, the re-assert lands in
|
||||
time and cadence should track speed. Next in-game test (fake-client Walk Circle)
|
||||
will show whether the proxy now jogs (Speed 180) vs. the old fixed slow walk, and
|
||||
the diagnostics will confirm the Update hook fires (`feedbackHookTotalCalls` large,
|
||||
`feedbackHookProxySkips` = re-assert count > 0).
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **SUCCESS** - `xmake build` clean.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-04 - Proxy Cadence: 0xCF Re-assert Holds Speed But Pose Is Generated Inside Update → Pivot to UpdateNoAI Puppet
|
||||
|
||||
### Diagnostic Run Confirms The Hook Works At The Data Level
|
||||
|
||||
With the `Actor::Update` (0xCF) re-assert in place, the fake-client Walk Circle run
|
||||
shows the hook is now correctly wired:
|
||||
|
||||
- `feedbackHookTotalCalls` climbs (848 → 1271) → the 0xCF `Actor::Update` hook IS
|
||||
dispatched virtually. Correct vtable slot (unlike the inert 0x129).
|
||||
- `feedbackHookProxySkips` climbs (257 → 647) → we re-assert Speed on the proxy
|
||||
every frame.
|
||||
- **`graphSpeedReadBack=true(180.0)`** while moving (was `0.0` before) → our written
|
||||
graph "Speed" now PERSISTS at the networked value. The per-frame clobber is
|
||||
defeated. Idle correctly drops to `0.0`.
|
||||
|
||||
### But Cadence Is Still Visually Unchanged
|
||||
|
||||
User confirms: the proxy still plays the same slow "medium walk"; no visible change
|
||||
in cadence. This proves the **Open Risk** from the prior entry: FO4 generates the
|
||||
animation pose INSIDE `Actor::Update` — after the engine's AI step resets Speed to 0
|
||||
(from the ~0 SetPosition controller velocity) but BEFORE our post-Update re-assert.
|
||||
So Speed holds the right number when we read it back at task time, yet the pose for
|
||||
the frame was already blended against Speed 0.
|
||||
|
||||
### Fix Pivot: UpdateNoAI Puppet
|
||||
|
||||
- For proxy FormIDs the hook no longer calls the full `Actor::Update`. Instead it:
|
||||
1. Writes the networked Speed/SpeedSampled,
|
||||
2. Calls `Actor::UpdateNoAI(float)` (vtable 0x0D0) — the engine's own
|
||||
"update minus AI" path, so the AI-driven Speed reset never runs,
|
||||
3. Re-writes Speed/SpeedSampled (belt-and-suspenders).
|
||||
- Real NPCs and the player are untouched (full `Update`), so their engine-driven
|
||||
locomotion is preserved.
|
||||
|
||||
### Open Risk
|
||||
|
||||
If `UpdateNoAI` omits work the proxy needs (e.g. 3D/position application, fade,
|
||||
attach), the proxy may stutter, fail to move, or T-pose. If so, revert to full
|
||||
`Update` and instead hook a finer point between the Speed reset and pose generation,
|
||||
or drive locomotion via controller velocity. Next in-game test (fake-client Walk
|
||||
Circle) decides: proxy should now jog at Speed 180 with a visibly faster cadence.
|
||||
|
||||
### Build Status
|
||||
|
||||
✅ **SUCCESS** - `xmake build` clean; DLL deployed to game F4SE/Plugins.
|
||||
Reference in New Issue
Block a user