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:
2026-06-04 20:18:34 +12:00
parent 2aab83087f
commit abef899505
11 changed files with 658 additions and 89 deletions
+170 -1
View File
@@ -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.