diff --git a/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md b/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md new file mode 100644 index 00000000..251051ac --- /dev/null +++ b/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md @@ -0,0 +1,726 @@ +# R3-W6 cutover-execution-map — local player motion unification + +Research-only doc. Re-anchors `r3-port-plan.md` §3 W6 (which cites stale +line numbers) against the CURRENT code, post-W1-W5. Every anchor below was +read directly from the working tree at HEAD (`e214acdf`) during this +session — no line number here is inherited from the plan. + +Inputs read: `r3-port-plan.md` §3 W6 + §1 row J15 + §4 (sink wiring +contract); `r3-motioninterp-decomp.md` §5c/§8 (`set_hold_run` callers); +`src/AcDream.App/Input/PlayerMovementController.cs` (full file, 1547 +lines); `src/AcDream.App/Rendering/GameWindow.cs` (targeted sections via +subagent + direct reads); `src/AcDream.Core/Physics/MotionInterpreter.cs` +(full file, 3083 lines); `src/AcDream.Core/Physics/RawMotionState.cs`; +`src/AcDream.Core/Physics/Motion/MovementParameters.cs`; the test tree. + +--- + +## §1 Current-state inventory — every member/block that DIES + +### 1a. Per-frame `RawMotionState` rebuild + `apply_raw_movement(RawMotionState)` call + +**`PlayerMovementController.cs:892-924`** (comment block + body, inside +`Update`). Every frame, regardless of whether input changed, builds a +fresh `RawMotionState` from `MovementInput` and pushes it through the +snapshot-consuming overload: + +```csharp +var axisHold = input.Run ? HoldKey.Run : HoldKey.None; +var raw = new RawMotionState { CurrentHoldKey = axisHold, ForwardCommand = ..., ... }; +_motion.apply_raw_movement(raw); +``` + +This is retail's `apply_raw_movement(RawMotionState raw)` overload +(`MotionInterpreter.cs:1346-1371`) — it copies 6 fields into +`InterpretedState` and normalizes each axis via `adjust_motion`, but does +**not** drive velocity or dispatch through a sink. It's a **level-triggered** +substitute for retail's **edge-triggered** `DoMotion`/`StopMotion`/ +`set_hold_run` calls that mutate `RawState` incrementally via +`RawMotionState.ApplyMotion`/`RemoveMotion`. DIES in W6 — replaced by §2's +edge table. + +### 1b. Section 2 grounded-velocity write (STAYS, not a W6 kill — flagged for clarity) + +**`PlayerMovementController.cs:962-991`**. Reads +`_motion.get_state_velocity()` and writes `_body.set_local_velocity(...)` +when grounded. The plan (§3 W6 "STAYS") keeps this — it's the R6-deferred +"grounded velocity from state" adaptation (register row). Listed here only +so the doc distinguishes it from 1a, which it's textually adjacent to. + +### 1c. Jump charge/fire block + +**`PlayerMovementController.cs:993-1043`**. Charge accumulates via +`_jumpCharging`/`_jumpExtent` (unchanged — AP-24, stays controller-side). +On release, calls: + +```csharp +var jumpResult = _motion.jump(_jumpExtent); +``` + +**Confirmed gap (not in the plan's text, found this session):** +`MotionInterpreter.ChargeJump()` (`MotionInterpreter.cs:1765-1789`, the +verbatim `charge_jump` port, the ONLY place `StandingLongJump` arms) is +**never called** by the controller today — only by +`MotionInterpreterJumpFamilyTests.cs`. The doc comment at +`MotionInterpreter.cs:1756-1762` says outright: *"no regression today +since nothing calls ChargeJump yet, so remotes/local both continue +unaffected."* W6 must add a `ChargeJump()` call at charge-START (the +retail caller is the SmartBox/input boundary at `0x0056afac`, out of +CMotionInterp's own scope but exactly where the controller's charge +block lives). Without this, `StandingLongJump` never arms for the local +player post-cutover either — same bug, just moved. See §6 risk R1. + +The manual `LeaveGround()` pre-capture this block used to need was +**already deleted in W4** (comment at `:1016-1021` confirms: "the manual +LeaveGround call is DELETED — jump() clears OnWalkable, and the SAME +frame's grounded→airborne edge... fires `_motion.LeaveGround()`"). This +part of the plan's W6 description is stale — it's W4-done, not a W6 task. + +### 1d. `LocalAnimationCommand` / `LocalAnimationSpeed` synthesis + +**`MovementResult` record fields**: `PlayerMovementController.cs:55` +(`LocalAnimationCommand`) and `:62` (`LocalAnimationSpeed`), part of the +record at `:42-65`. + +**Synthesis sites**: +- `:1308-1322` — `localAnimCmd` set from `input.Forward`/`input.Backward` + + `_weenie.InqRunRate` (Run cycle selection independent of wire command). +- `:1443-1481` — K-fix5 `localAnimSpeed` computation (run-rate pacing for + Backward+Run/Strafe+Run) + auto-walk override block + (`_autoWalkMovingForwardThisFrame`/`_autoWalkTurnDirectionThisFrame` + branches overwrite `localAnimCmd`/`localAnimSpeed`). +- `:1355-1377` — change-detection block includes `localAnimCmd != + _prevLocalAnimCmd` as a `changed` trigger (7th line of the OR-chain, + `:1371`) and tracks `_prevLocalAnimCmd` (`:191`, `:1377`). +- `:1503-1504` — final `MovementResult` construction passes both fields + through. + +DIES: the two fields, all three synthesis sites, and the +`_prevLocalAnimCmd` change-detection leg (the OTHER six `changed` +triggers survive unchanged — see §5). + +### 1e. `SetPosition`'s arrival-idle `DoMotion(Ready)` + +**`PlayerMovementController.cs:839`**, inside `SetPosition(Vector3, uint, +Vector3)`: + +```csharp +_motion.DoMotion(MotionCommand.Ready, 1.0f); +``` + +Per the plan: "the `:825` `DoMotion(Ready)` arrival-idle becomes +`StopCompletely()` — retail's teleport idle is a full stop." Confirmed +`StopCompletely()` (`MotionInterpreter.cs:1054-1084`) is the correct +retail-faithful substitute: it resets forward/sidestep/turn COMMANDS +(not speeds, per J9), zeros `PhysicsObj.set_velocity`, enqueues the A9 +jump-snapshot node, and fires `RemoveLinkAnimations` if uncelled — a +strictly more complete reset than the current one-field `DoMotion(Ready)` +call. Same substitution applies to the identical call inside +`DriveServerAutoWalk`'s turn-in-place branch (`:732`) — **the plan does +NOT call this one out**, but it is textually the same pattern +(`_motion.DoMotion(MotionCommand.Ready, 1.0f)`) serving the same "go +idle" role mid-auto-walk. Flagged as a judgment call in §6 (R4) — recommend +leaving `:732` as `DoMotion(Ready)` since auto-walk survives R3 verbatim +per the KEEP LIST and StopCompletely's jump-snapshot/queue side effects +are not obviously wanted mid-auto-walk-turn. + +### 1f. Shift/Run detection + +**Today**: `input.Run` is read directly into `axisHold` (1a) every frame +— no edge detection exists; the level-triggered rebuild makes edge +detection moot today. W6 replaces this with an explicit prev-Shift-state +edge feeding `set_hold_run(bool holdingRun, bool interrupt)` +(`MotionInterpreter.cs:2453-2463`), which itself has an internal XOR +no-op guard (so calling it every frame with the same value is safe, but +the controller should still gate on an edge per the plan's "tiny +prev-key-state edge detector"). + +### 1g. Outbound section 6 — re-derivation from `input` + +**`PlayerMovementController.cs:1288-1354`** (`outForwardCmd`/ +`outForwardSpeed`/`outSidestepCmd`/etc.) re-derives the wire commands +from `MovementInput` fields (`input.Forward`, `input.StrafeRight`, etc.) +completely independently of `_motion.RawState`, which by this point in +the frame (post 1a) already holds equivalent values. This is redundant +computation that DIES — the outbound-wire-build in GameWindow (§4/§5) +should read `_motion.RawState` (now the SAME instance the controller has +been mutating via `DoMotion`/`StopMotion`/`set_hold_run`) directly instead +of `MovementResult.ForwardCommand`/etc. **Note**: this doc treats "which +fields of `MovementResult` survive" as a §5 design decision, not a +foregone conclusion — see §5. + +### 1h. GameWindow: `UpdatePlayerAnimation` (DELETE) + its caller + +**Definition**: `GameWindow.cs:10139-10307` (169 lines). +**Single caller**: `GameWindow.cs:8006`, inside `OnUpdate`'s +`if (_playerController is not null)` block, immediately after the +JumpAction wire-send block (`:7984-8002`). + +Reads `result.IsOnGround` (airborne→Falling override, `:10164-10165`), +`result.LocalAnimationCommand` (`:10166`, preferred source), +`result.ForwardCommand`/`SidestepCommand`/`TurnCommand` (`:10168-10172`, +fallback chain when `LocalAnimationCommand` is null), +`result.LocalAnimationSpeed` (`:10186`, `:10255-10257`, feeds `SetCycle`). +Calls `ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * +animScale)` directly (`:10297`) — this is a **parallel path** to +`MotionTableDispatchSink.ApplyMotion` that remotes use; it does NOT go +through `_playerController.Motion` at all. DIES in full — the player's +`AnimatedEntity.Sequencer` gets driven through the SAME +`MotionTableDispatchSink` → `DefaultSink` funnel remotes use (§3). + +### 1i. `input.` reads for MOTION (not camera) — full inventory + +All in `PlayerMovementController.Update`, all replaced by edge calls +except where noted: +- `input.Forward`/`input.Backward` (`:907-909`, `:1309-1322`) → W press/S + press/release edges. +- `input.StrafeLeft`/`input.StrafeRight` (`:912-914`, `:1328-1337`) → + strafe press/release edges. +- `input.TurnLeft`/`input.TurnRight` (`:917-919`, `:1344-1353`) → turn + press/release edges. **Note**: `input.TurnRight`/`TurnLeft` ALSO drive + Yaw integration directly at `:946-951` — that consumption STAYS (reads + `_motion.InterpretedState.TurnCommand`, unchanged per the plan). +- `input.Run` (`:903`, `:910`, `:915`, `:920`, `:1313`, `:1365`, + `:1445-1446`, `:1502`) → collapses to the Shift edge → `set_hold_run`. +- `input.Jump` (`:1000`) → STAYS as the charge/fire trigger (unchanged + shape, gains the `ChargeJump()` call per 1c). +- `input.MouseDeltaX` (`:952`) → STAYS (camera/Yaw only, never a motion + command — deliberately, per the existing comment on why mouse-turn + generates no wire command). + +--- + +## §2 The edge-detector design + +### 2a. Minimal prev-state + +The controller needs exactly 6 booleans (one per discrete axis-direction) +plus the existing Shift/Run bool, replacing the current +`_prevForwardCmd`/`_prevSidestepCmd`/`_prevTurnCmd`/`_prevForwardSpeed`/ +`_prevRunHold`/`_prevLocalAnimCmd` fields (`:186-191`) which tracked +OUTPUT state for change-detection. The new fields track INPUT state for +edge-detection: + +```csharp +private bool _prevForwardHeld; // input.Forward +private bool _prevBackwardHeld; // input.Backward +private bool _prevStrafeLeftHeld; +private bool _prevStrafeRightHeld; +private bool _prevTurnLeftHeld; +private bool _prevTurnRightHeld; +private bool _prevRunHeld; // input.Run (Shift) +``` + +Six of the seven map 1:1 onto retail's per-axis `RawMotionState` channels +(forward, sidestep, turn — each a single "current command" slot, so +Forward-press-while-Backward-held needs the SAME "switch channel" handling +`ApplyMotion` already does verbatim — no new logic needed, just route +both to the forward channel). + +### 2b. Edge → call table + +| Edge | Call | MovementParameters | +|---|---|---| +| Forward pressed (was false, now true) | `DoMotion(MotionCommand.WalkForward, p)` | ctor defaults (`new MovementParameters()`) — `Speed=1f` is the retail raw speed (ACE recomputes broadcast speed; D6.2b finding already established this). | +| Forward released (was true, now false), AND Backward not held | `StopMotion(MotionCommand.WalkForward, p)` | ctor defaults. | +| Backward pressed | `DoMotion(MotionCommand.WalkBackward, p)` | ctor defaults (`adjust_motion` applies `BackwardsFactor` — unchanged, already verbatim). | +| Backward released, Forward not held | `StopMotion(MotionCommand.WalkBackward, p)` | ctor defaults. | +| StrafeRight pressed | `DoMotion(MotionCommand.SideStepRight, p)` | ctor defaults. | +| StrafeLeft pressed | `DoMotion(MotionCommand.SideStepLeft, p)` | ctor defaults. | +| Strafe released (both false) | `StopMotion(MotionCommand.SideStepRight, p)` (retail always stops via the RIGHT id per `adjust_motion`'s SideStepLeft→SideStepRight canonicalization — confirmed in `RawMotionState.ApplyMotion`'s switch, which treats `0x6500000f`/`0x65000010` as the same channel) | ctor defaults. | +| TurnRight pressed | `DoMotion(MotionCommand.TurnRight, p)` | ctor defaults. | +| TurnLeft pressed | `DoMotion(MotionCommand.TurnLeft, p)` | ctor defaults. | +| Turn released (both false) | `StopMotion(MotionCommand.TurnRight, p)` | ctor defaults. | +| Shift edge (any transition) | `set_hold_run(input.Run, interrupt: true)` | N/A — direct call, no `MovementParameters`. `interrupt:true` matches the decomp's SECOND caller (`006b33ca`, `arg3=1`, explicitly the "player input/command layer" call site per `r3-motioninterp-decomp.md:1001`) — the FIRST caller (`0058b303`) omits arg3 in the decomp text (ambiguous, likely also non-zero given calling convention defaults per the decomp's own hedge) and is not the player-input site anyway. | +| Jump release (existing charge-fire block) | `_motion.jump(_jumpExtent)` — UNCHANGED call, only gains the `ChargeJump()` call at charge-START (see §1c/§6 R1) | N/A — `jump(float, int)` takes no `MovementParameters`. | +| Auto-walk `DoMotion(Ready)`/`DoMotion(WalkForward)` (`:732`, `:767`) | UNCHANGED — plan explicitly keeps this. | 2-arg compat overload, unchanged. | + +**TODO flag (per the task's own instruction)**: the decomp does not show +a `DoMotion`/`StopMotion` call site at the CommandInterpreter boundary — +only `set_hold_run`'s two callers are visible in +`r3-motioninterp-decomp.md` §5c/§8 (`0058b303`/`006b33ca`). The +DoMotion/StopMotion edge calls above are this doc's inference from the +verbatim `DoMotion`/`StopMotion` bodies' own defaulting behavior (both +build a **fresh** `MovementParameters` internally for the +`DoInterpretedMotion`/`StopInterpretedMotion` tail-call regardless of what +the caller passes — see `MotionInterpreter.cs:915`, `:992` — so the +caller-supplied `p`'s only LIVE effects are `CancelMoveTo` (interrupt), +`SetHoldKey`+`HoldKeyToApply` (hold-key push), `Speed`, and +`ModifyRawState`/`ModifyInterpretedState` gates). Ctor defaults +(`CancelMoveTo=true, SetHoldKey=true, ModifyRawState=true, +ModifyInterpretedState=true, Speed=1f, HoldKeyToApply=Invalid`) are the +right choice UNLESS retail's real CommandInterpreter caller passes +`HoldKeyToApply` per-axis (plausible for Run) — **TODO(W6 impl): verify +against a fresh cdb capture of the CommandInterpreter boundary before +implementing**, per the task's own note. This doc marks it a TODO rather +than guessing further, consistent with CLAUDE.md's "do not guess" rule. + +### 2c. Why `DoMotion`'s own re-defaulting makes this safe + +`DoMotion(motion, p)` (`MotionInterpreter.cs:908-949`) builds `var local = +new MovementParameters()` (fresh re-default) and only carries +`local.Speed = speed` and `local.HoldKeyToApply = p.HoldKeyToApply` +through to `DoInterpretedMotion`. The CALLER's `p` only matters for: +`p.CancelMoveTo` (interrupt-before-dispatch), `p.SetHoldKey` (whether to +call `SetHoldKey` first), `p.ModifyRawState` (whether the raw-state mirror +runs at the end). This means the controller passing ctor-default +`MovementParameters` per edge is not a simplification that loses +information — it is EXACTLY what retail's own `DoMotion` does internally +regardless of caller intent, for every field except the four above. Low +risk. + +--- + +## §3 The player sequencer drive — tracing DefaultSink to the sequencer + +### 3a. Call path confirmed present, ONE binding missing + +Full trace, verified directly in `MotionInterpreter.cs`: + +1. Controller calls `DoMotion(motion, p)` (edge) → +2. `DoMotion` → `DoInterpretedMotion(motion, local)` (`:943`) → +3. `DoInterpretedMotion(uint, MovementParameters)` (`:2837-2838`) → + `DoInterpretedMotion(motion, p, DefaultSink)` (the sink-parameterized + core) → +4. Inside (`:2850-2947`): `sink?.ApplyMotion(motion, p.Speed)` (`:2884`) + — **this is the dispatch to the animation backend**. + +Separately, for the CONTINUOUS axes (forward/sidestep/turn re-applied +every physics event, e.g. on `LeaveGround`/`HitGround`/`set_hold_run`): + +1. `set_hold_run`/`SetHoldKey`/`LeaveGround`/`HitGround` all call + `apply_current_movement(cancelMoveTo, allowJump)` (`:1404-1417`) → +2. Dual-dispatch gate (A3): `isThePlayer = WeenieObj is null || + WeenieObj.IsThePlayer()`. Local player: `WeenieObj` is + `PlayerWeenie` (constructed at `PlayerMovementController.cs:354`, + presumably `IsThePlayer() == true` — **needs one-line confirmation in + IWeenieObject/PlayerWeenie, not re-derived here**) AND + `PhysicsObj.LastMoveWasAutonomous` is set `true` at construction + (`PlayerMovementController.cs:360`, with the comment "R3-W6 refines + this per-motion") → routes to `apply_raw_movement(cancelMoveTo, + allowJump)` (the bool-arg overload, `:1443-1450`) → +3. That overload calls `apply_raw_movement(RawState)` (mutates + `InterpretedState` from the interpreter's OWN `RawState` — no longer + an externally-built snapshot, this is the key W6 shift) then + `ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump)` (`:1449`) → +4. `ApplyCurrentMovementInterpreted` (`:1491-1536`): `if (DefaultSink is + not null) { ApplyInterpretedMovement(InterpretedState.CurrentStyle, + DefaultSink, cancelMoveTo, allowJump); return; }` (`:1502-1506`) — + **this IS the full funnel dispatch** (style → forward-or-Falling → + sidestep(-stop) → turn(-stop)), the SAME `ApplyInterpretedMovement` + the inbound remote funnel uses and the SAME dispatch-order the S2a + 183-case suite pins. + +**CONFIRMED: the call path from an edge-driven `DoMotion`/`set_hold_run` +all the way to `AnimationSequencer.SetCycle`/`PerformMovement` already +exists in Core, fully wired, contingent on ONE thing: `DefaultSink` must +be non-null for the player's `MotionInterpreter`.** + +### 3b. The missing binding — confirmed via direct read + subagent cross-check + +`MotionInterpreter.cs:1479-1489` doc comment states explicitly: *"Null +(the local player until R3-W6, interp-less entities) → the AP-77 +physics-only tail."* Confirmed live: at +`GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), the player's +`_playerController.Motion` gets THREE of the four seams bound +(`RemoveLinkAnimations`, `InitializeMotionTables`, +`CheckForCompletedMotions`) but explicitly NOT `DefaultSink` — inline +comment: *"DefaultSink stays null until R3-W6 (UpdatePlayerAnimation +still drives the player's cycles)."* + +Compare against the remote pattern, `EnsureRemoteMotionBindings` +(`GameWindow.cs:4195-4224`), called lazily from `OnLiveMotionUpdated` +(`:4679`) and `OnLiveVectorUpdated`(`:4859`): + +```csharp +rm.Sink = new MotionTableDispatchSink(ae.Sequencer) +{ + TurnApplied = (turnMotion, turnSpeed) => { /* ObservedOmega seed */ }, + TurnStopped = () => rmForSink.ObservedOmega = Vector3.Zero, +}; +rm.Motion.DefaultSink = rm.Sink; // <-- MISSING for player +rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations; +rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); +rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions; +``` + +**W6 action**: at `GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), +construct a `MotionTableDispatchSink(playerSeq)` and bind +`_playerController.Motion.DefaultSink = ` alongside the three +existing binds. The `TurnApplied`/`TurnStopped` callbacks exist for the +`ObservedOmega` remote dead-reckoning seed — the LOCAL player has no +`ObservedOmega` concept (it's a remote-only field on `RemoteMotion`), so +these two callbacks can be no-ops for the player's sink, OR omitted +entirely if `MotionTableDispatchSink`'s constructor doesn't require them +(confirm the class's actual required-vs-optional members before +implementing — not independently re-verified in this pass beyond the +subagent's citation of `MotionTableDispatchSink.cs:34-72`). + +**Where to store the sink instance**: unlike `RemoteMotion` (a wrapper +struct with `.Sink`/`.Motion`/`.ObservedOmega` fields), the player has no +equivalent wrapper — `_playerController` plays that role directly. The +sink instance needs a home: either (a) a new field on +`PlayerMovementController` (parallels `Motion` being exposed there +already), or (b) a `GameWindow`-local field alongside the existing +`_playerController` field. Recommend (a) for symmetry with `Motion`'s +existing exposure pattern (`PlayerMovementController.cs:375`) — a new +`internal MotionTableDispatchSink? Sink { get; set; }` property, set from +`EnterPlayerModeNow`, mirrors `RemoteMotion.Sink` exactly. + +### 3c. What this replaces + +Once `DefaultSink` is bound, `UpdatePlayerAnimation`'s direct +`ae.Sequencer.SetCycle(...)` call becomes fully redundant — the funnel +dispatch (`ApplyInterpretedMovement` → `DefaultSink.ApplyMotion` → +`MotionTableDispatchSink.ApplyMotion` → `sequencer.PerformMovement`) now +fires on every edge-driven `DoMotion`/`StopMotion`/`set_hold_run`/ +`LeaveGround`/`HitGround` call, exactly mirroring how remotes already +work. The airborne→Falling swap "falls out of `contact_allows_move` + +`apply_current_movement`" per the plan — confirmed: `ApplyInterpretedMovement` +(`:2731-2733`) already has `if (!contact_allows_move(...)) +DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);` as its +SECOND dispatch (unconditional check every call), so once the player's +`apply_current_movement` reaches this funnel on the LeaveGround edge, the +Falling swap happens automatically with no App-side airborne branch +needed at all — `UpdatePlayerAnimation`'s `:10164-10165` airborne +override becomes dead code, confirming full deletion is correct, not just +plumbing-safe. + +--- + +## §4 GameWindow edit list + +| # | Current anchor | What replaces it | +|---|---|---| +| 1 | `UpdatePlayerAnimation` definition, `:10139-10307` | DELETE the method entirely. | +| 2 | `UpdatePlayerAnimation(result)` call, `:8006` | DELETE the call (the tail of the `if (_playerController is not null)` block in `OnUpdate`). | +| 3 | `EnterPlayerModeNow`, binding block `:12987-13000` | ADD the `DefaultSink` bind (§3b) as a 4th line alongside the existing 3 seam binds. | +| 4 | Outbound wire-build block, `:7893-7913` (fresh `RawMotionState` built from `MovementResult` fields) | Read `_playerController.Motion.RawState` directly instead of re-deriving. See §5 for exactly which fields still need `MovementResult` vs which now come from `RawState`. | +| 5 | `ApplyServerRunRate` call site, `OnLiveMotionUpdated` `:4373` (per subagent citation) | UNCHANGED — `ApplyServerRunRate` (`PlayerMovementController.cs:410-414`) already calls `_motion.apply_current_movement(false, false)`, which after 3b's bind now ALSO drives the sequencer via `DefaultSink` (a currently-inert call becomes live) — no GameWindow-side edit needed, but flag as a **behavior change**, not just plumbing (see §6 R2). | +| 6 | JumpAction wire-build block, `:7984-8002` | UNCHANGED — still reads `result.JumpExtent`/`result.JumpVelocity` from `MovementResult` (§5 keeps these fields; the jump path is not touched by the DoMotion/StopMotion edge redesign). | +| 7 | `OnLiveVectorUpdated`'s remote-jump `LeaveGround()`+`DefaultSink` pattern, `:4845-4864` | UNCHANGED — this is the ALREADY-MIGRATED reference pattern (W4/J19 closed) that item 3 mirrors for the player. Cited here only as the proof-of-pattern, not an edit site. | + +Two GameWindow items the plan names that turned out to be **already +resolved, not pending W6 work** (found this session, not anticipated by +the plan text): + +- **`skipTransitionLink`**: confirmed via repo-wide grep — zero + occurrences in any production `.cs` file. Fully deleted in W4. The + plan's W6 bullet listing this as something to delete is stale; no + action needed. +- **`ACDREAM_ANIM_SPEED_SCALE`/#45 sidestep factor**: both live entirely + inside the doomed `UpdatePlayerAnimation` (`:10258-10295`) — they die + WITH the method, they don't need a separate "move into the + CMotionTable-driven speedMod path" step, because there is no equivalent + concept upstream in `MotionTableDispatchSink`/`AnimationSequencer. + PerformMovement` today. See §6 R3 — this is a real behavior-loss risk, + not a mechanical relocation, and needs a decision before deletion. + +--- + +## §5 Wire-parity plan + +### 5a. Current outbound flow (pre-W6) + +`GameWindow.cs:7893-7913` builds a FRESH `RawMotionState` from +`MovementResult.ForwardCommand`/`SidestepCommand`/`TurnCommand`/ +`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`, then passes it to +`MoveToState.Build(...)` (`:7916`), which internally calls +`RawMotionStatePacker.Pack(w, rawMotionState)` +(`MoveToState.cs:80`). + +### 5b. Post-W6 target flow + +`_playerController.Motion.RawState` is, by construction, the SAME +`RawMotionState` instance the edge-driven `DoMotion`/`StopMotion` calls +have been mutating via `ApplyMotion`/`RemoveMotion` all frame (per J2's +W1 fold: `RawState` IS the packer-compatible type, action FIFO included). +GameWindow's outbound block should read directly from +`_playerController.Motion.RawState` instead of reconstructing one. + +**Field-by-field disposition**: + +| `RawMotionState` field | Source post-W6 | Notes | +|---|---|---| +| `CurrentHoldKey` | `RawState.CurrentHoldKey`, set by `set_hold_run` edge | Was `result.IsRunning ? Run : None` — now the interpreter's own tracked value. | +| `ForwardCommand`/`ForwardHoldKey`/`ForwardSpeed` | `RawState.*`, set by `ApplyMotion`/`RemoveMotion` on the Forward/Backward edges | Was `result.ForwardCommand ?? Default`. | +| `SidestepCommand`/`SidestepHoldKey`/`SidestepSpeed` | `RawState.*` | Was `result.SidestepCommand ?? Default`. | +| `TurnCommand`/`TurnHoldKey`/`TurnSpeed` | `RawState.*` | Was `result.TurnCommand ?? Default`. | +| `Actions` | `RawState.Actions` | Was ALWAYS EMPTY pre-W1 (TS-24); post-W1 populated by `ApplyMotion`'s action-class branch — this is a genuine NEW behavior surfacing on the wire once W6 reads live `RawState` instead of a `MovementResult`-rebuilt empty-actions snapshot. Flag in §6 R5. | + +**`MovementResult` fields that SURVIVE** (still needed by GameWindow for +non-wire purposes): `Position`, `RenderPosition`, `CellId`, `IsOnGround` +(camera/HUD), `JustLanded` (landing SFX/anim triggers if any exist +outside `UpdatePlayerAnimation` — verify no other consumer before +deleting used-only-by-UpdatePlayerAnimation fields), `JumpExtent`/ +`JumpVelocity` (JumpAction wire build, item 6 in §4, untouched by this +redesign since jump keeps its existing dedicated path). + +**`MovementResult` fields that DIE**: `LocalAnimationCommand`, +`LocalAnimationSpeed` (§1d). **Candidates for deletion pending §5c +decision**: `ForwardCommand`/`SidestepCommand`/`TurnCommand`/ +`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`/ +`MotionStateChanged` — these become redundant with `RawState` reads, but +deleting them is a larger surface change to `MovementResult`'s consumers +(the golden-byte tests test `RawMotionStatePacker`/`MoveToState` +directly, NOT `MovementResult`, so they're unaffected either way — see +5d). Recommend KEEPING these fields in `MovementResult` for this W6 slice +(mark `[Obsolete]` or comment "now diagnostic-only, GameWindow reads +RawState") rather than deleting, to keep the W6 diff scoped to the +motion-drive mechanism, not a `MovementResult` API redesign — a follow-up +cleanup commit can trim the struct once nothing reads it. + +### 5c. `MotionStateChanged` gate — needs a replacement signal + +`GameWindow.cs:7874` gates the whole outbound MoveToState build on +`result.MotionStateChanged && !_playerController.IsServerAutoWalking`. +Post-W6, `MovementResult.MotionStateChanged` (computed from the DYING +`_prevForwardCmd`/etc. output-comparison block, §1d) needs a replacement. +**Recommend**: derive change-detection from the edge table itself — ANY +edge fired this frame (forward/backward/strafe/turn/run edges, §2a) is +definitionally a state change; expose a `bool MotionChangedThisFrame` +computed from `_prevXHeld != inputXHeld` ORed across all axes, computed +in the SAME `Update` call, replacing the output-comparison approach with +an input-edge approach. This is a strictly simpler equivalent (retail's +CommandInterpreter only calls `DoMotion`/`StopMotion` ON edges in the +first place, so "an edge fired" and "state changed" are the same +predicate by construction — no separate comparison needed). Confirm this +doesn't regress the `runHold`-without-directional-edge case (Shift toggle +while already moving) — the edge table's Shift-edge row already covers +this (`set_hold_run` fires independently of the directional edges), so +OR-ing in the Shift edge too closes that case. + +### 5d. Golden-byte tests — confirmed unaffected + +`RawMotionStatePackTests.cs`, `MoveToStateGoldenTests.cs`, +`MoveToStateTests.cs`, `AutonomousPositionTests.cs`, `JumpActionTests.cs`, +`PositionPackTests.cs` (all under +`tests/AcDream.Core.Net.Tests/Messages/`) construct `RawMotionState`/call +`MoveToState.Build` with EXPLICIT field values directly — none reference +`PlayerMovementController` or `MovementResult`. They pin +`RawMotionStatePacker`'s bit-packing contract, which W6 does not touch +(only WHERE the packed values are sourced from changes). These stay green +as a structural guarantee, not something W6 needs to specifically verify +beyond "still compiles, still passes" — but a NEW test is needed for the +end-to-end path: build a `RawMotionState` via live `DoMotion`/`StopMotion` +edges, feed it to `RawMotionStatePacker.Pack`, and diff against the SAME +byte sequence the pre-cutover `MovementResult`-rebuilt path produced for +an identical input script (see §7). + +--- + +## §6 Risk list + +**R1 — `ChargeJump()` never called (BLOCKER-adjacent discovery, not in +the plan text).** Confirmed via grep: `MotionInterpreter.ChargeJump()` +has zero production call sites — only `MotionInterpreterJumpFamilyTests.cs` +calls it. `StandingLongJump` therefore never arms for ANY entity today +(local or remote), despite W3 having fully ported the mechanism. W6's +jump-charge block (§1c) MUST add a `ChargeJump()` call when charge begins +(mirroring `input.Jump && !_jumpCharging` → also call `_motion.ChargeJump()`, +checking its `WeenieError` return the same way the fire-path already +checks `jump()`'s). Without this, the "standing long-jump" feature stays +silently dead post-cutover — same bug, just no longer excusable as "W3 +hasn't wired the call site yet." **Recommendation: treat as in-scope for +W6, not a separate follow-up** — it's the SAME jump-charge block W6 is +already editing to add `ChargeJump`'s sibling gate calls. + +**R2 — `ApplyServerRunRate`'s `apply_current_movement` call goes from +inert to live.** Pre-W6, `ApplyServerRunRate` (`PlayerMovementController.cs: +410-414`) calls `_motion.apply_current_movement(false, false)`, but +`DefaultSink` is null so `ApplyCurrentMovementInterpreted` falls through +to the AP-77 physics-only tail (a `get_state_velocity` → `set_local_velocity` +write, no sequencer touch). Post-W6 (once `DefaultSink` is bound, §3b), +the SAME call now ALSO dispatches through the funnel +(`ApplyInterpretedMovement` → `SetCycle`) on every server RunRate echo — +this could fire a redundant/premature `SetCycle` call on every +`UpdateMotion (0xF61C)` echo for the player's own guid, independent of +any local edge. **Recommendation**: verify this doesn't cause visible +cycle-restart flicker (retail's real `apply_current_movement` call here IS +supposed to re-dispatch — this may be CORRECT retail behavior surfacing +for the first time — but it's a genuine visual-risk item for the "ONE +user visual pass" acceptance test, not a mechanical no-op). + +**R3 — `#45` sidestep factor + `ACDREAM_ANIM_SPEED_SCALE` have no +upstream equivalent.** Per §4, both die with `UpdatePlayerAnimation` +because `MotionTableDispatchSink.ApplyMotion`/`AnimationSequencer. +PerformMovement` (the remote path, now shared) has no known +sidestep-specific speed-scale multiplier or global visual-pacing knob. +Two sub-risks: (a) sidestep animation pacing may visibly change (the +1.248× factor matched ACE's `BroadcastMovement` wire formula — losing it +means the sidestep CYCLE plays at `SidestepAnimSpeed`-derived pace instead +of the wire-matched pace); (b) `ACDREAM_ANIM_SPEED_SCALE` (a debug/tuning +knob) simply stops having any effect for the local player post-cutover +(remotes never had it either, so this makes local and remote consistent — +arguably correct, but a functionality LOSS for whoever uses that env var). +**Recommendation**: confirm with the user whether (a) is an +expected-diff (remotes today do NOT get the 1.248× factor either — so +post-cutover the LOCAL player's sidestep pacing becomes consistent with +how remotes have always looked, which may in fact be the retail-faithful +answer the #45 comment's "matching ACE's wire formula" was compensating +for a DIFFERENT bug in) before deleting; document as an expected-diff in +the W6 commit either way, per the plan's own instruction ("expected-diff +annotations required"). + +**R4 — `DriveServerAutoWalk`'s `DoMotion(Ready)` at `:732` is not named +in the plan's W6 SetPosition-only StopCompletely note.** §1e flags this +textual sibling. Auto-walk survives R3 verbatim per the KEEP LIST, so +changing `:732` is OUT of scope — but a future reader diffing "every +`DoMotion(Ready)` call site" against the plan's single cited line (`:825` +old numbering) could reasonably assume both change. **Recommendation**: +leave `:732` untouched, but add a one-line comment there cross-referencing +this doc so the distinction is explicit at the code site, not just here. + +**R5 — `RawState.Actions` starts reaching the wire for the first time.** +Per §5b, reading live `RawState` instead of a `MovementResult`-rebuilt +snapshot means the `Actions` FIFO (populated since W1/W2 by +`ApplyMotion`'s action-class branch, e.g. `motion & 0x10000000` bit — +action-class motions like emotes/attacks IF the local player ever calls +`DoMotion` with an action-class id) starts appearing in the packed +`MoveToState` bytes where it was previously always empty (TS-24). If +nothing in the current player-input path ever issues an action-class +`DoMotion` call, this is a no-op in practice for W6's scope (movement axes +only) — but flag it so a future action/emote wiring phase doesn't get +blindsided by "wait, did this already work?" + +**R6 — `IWeenieObject.IsThePlayer()` for `PlayerWeenie` not +independently re-verified this session.** §3a's dispatch-path trace +ASSUMES `PlayerWeenie.IsThePlayer() == true` (required for +`apply_current_movement`'s A3 gate to route to `apply_raw_movement`, +which is the path that reaches `DefaultSink`). This is almost certainly +already correct (W3 added `IsThePlayer()` to `IWeenieObject` specifically +for this dispatch, per J5's gap list), but a W6 implementer should grep +`PlayerWeenie.cs` for the override before writing code, not assume from +this doc alone. + +--- + +## §7 Test plan + +### 7a. New unit tests — edge table + +Add to `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs` +(or a new `PlayerMovementControllerEdgeTests.cs` alongside it, matching +the existing per-concern file split seen in +`DispatcherToMovementIntegrationTests.cs`): + +- Press-W → `_motion.RawState.ForwardCommand == WalkForward` after one + `Update` call (was: never directly observable pre-W6 since the raw + state was an ephemeral per-frame local; post-W6 it's the persistent + field — this test is only possible/meaningful after the cutover). +- Press-W, hold 3 frames, release → `RawState.ForwardCommand == + MotionCommand.Ready` after release frame (StopMotion fired). +- Press-W then press-S same frame → forward channel ends on S (or + whichever wins per `ApplyMotion`'s single-channel-per-axis semantics — + pin the actual retail-faithful tie-break, likely "last processed wins" + given the controller processes Forward before Backward in its own + `MovementInput` field order — assert whichever the implementation + produces, don't guess a preference here). +- Shift press while W held → `RawState.CurrentHoldKey == Run` + + `set_hold_run`'s internal `apply_current_movement` fired (observable + via a velocity change or a `DefaultSink` mock's call count once 3b + lands). + Shift press while NOT moving → `CurrentHoldKey == Run` still updates + (retail's `set_hold_run` doesn't gate on movement) but no velocity + visible change. +- Redundant Shift-held-while-already-Run (two consecutive `Update` calls + with `input.Run=true`) → `set_hold_run`'s XOR guard no-ops on the + second call (assert via a call-counting mock on + `apply_current_movement`'s side effect, e.g. `DefaultSink.ApplyMotion` + invocation count stays flat). +- Jump charge-then-release still produces `WeenieError.None` from + `jump()` AND (new) `ChargeJump()` was called at charge-start with a + `StandingLongJump` arm when idle+grounded (R1's fix, needs its own + assertion — grounded+idle+charge+release → `StandingLongJump == true` + observable via `_motion.StandingLongJump` before the release clears it, + or via the funnel's state-only dispatch branch firing). +- SetPosition (teleport) → `RawState.ForwardCommand == Ready` AND + `PendingMotions` gained the A9 snapshot node (StopCompletely's + `AddToQueue` call) — this is a NEW observable side effect vs the old + `DoMotion(Ready)` call, worth a dedicated assertion. + +### 7b. Existing tests needing re-pin + +Per the tests-agent's findings: + +- **`PlayerMovementControllerTests.cs`** (15 facts, 29 `Update` calls) — + the two jump tests (`Update_JumpOnFlatTerrain_BecomesAirborne`, + `Update_AirborneFrames_ZRiseThenFalls`) are HIGH risk for needing + re-pin: they exercise the exact charge→release block §1c/R1 touches. + Re-run after the `ChargeJump()` addition — if `StandingLongJump` arms + during a stationary charge-then-release-while-still-idle scenario these + tests use, a NEW dispatch path (`DispatchInterpretedMotion(Ready)` + + `DispatchStopInterpretedMotion(SideStepRight)` per + `ApplyInterpretedMovement`'s StandingLongJump branch) fires that didn't + before — verify it doesn't change `IsAirborne`/`VerticalVelocity`/ + `Position.Z` assertions (it shouldn't, since that branch is state-only + no-dispatch-to-velocity per J6, but MUST be checked, not assumed). +- **`DispatcherToMovementIntegrationTests.cs`** (6 facts) — asserts + dispatcher-built `MovementInput` produces IDENTICAL results to + hand-built input. Should stay green unchanged (it exercises the + `MovementInput`→`Update` boundary, not the internals W6 rewrites) — + but re-run explicitly since it's the integration seam most likely to + surface an edge-detection regression (e.g. if a test builds two + DIFFERENT `MovementInput` values across two `Update` calls without the + edge-detector's `_prevXHeld` fields being exercised the same way a + real per-frame loop would). +- **`CellarUpTrajectoryReplayTests.cs`**'s + `IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` — a + 600-frame bit-stability regression guard with NO input held. Should be + UNAFFECTED (no edges ever fire), but it's cited in memory + (`feedback_render_perf_measurement.md`-adjacent class of test) as a + historically fragile guard — run it explicitly, don't assume "no input + means no risk" given this test exists specifically because a prior + change broke exactly this "nothing should happen when nothing is + pressed" invariant. + +### 7c. Pre-cutover trace capture (plan-mandated, not yet done) + +Per the plan's Fixture source line: "pre-cutover recorded traces + +golden-byte + suite." This doc does not capture the trace (out of +research-only scope) but flags that the W6 IMPLEMENTATION session must +run `ACDREAM_DUMP_MOTION=1` (or the equivalent `SetCycle`/dispatch trace +capture) against the CURRENT (pre-W6) code first, save the log, THEN +implement the cutover, THEN replay the same input script and diff — this +is a sequencing dependency on the implementation session, not something +this research pass can pre-stage without running the live client. + +### 7d. Suites that must stay green throughout, unmodified + +`RetailObserverTraceConformanceTests.cs` (184-case funnel dispatch-order +suite) + `MotionInterpreterFunnelTests.cs` (13 facts, synthetic +action-class supplement) — both exercise the INBOUND remote funnel path +(`DispatchInterpretedMotion`/`MoveToInterpretedState`), a sibling path to +what W6 touches. Should require zero changes; green throughout is the +signal that W6's edits to the SHARED `MotionInterpreter` machinery +(`DoMotion`/`PerformMovement`/`apply_current_movement`) didn't perturb +the remote dispatch order. All six golden-byte packer test files (§5d) +likewise stay green unmodified. + +--- + +## Anchor summary (file:line, this session, HEAD `e214acdf`) + +- `src/AcDream.App/Input/PlayerMovementController.cs` — full file read, + 1547 lines. +- `src/AcDream.App/Rendering/GameWindow.cs` — `UpdatePlayerAnimation` + `:10139-10307`; caller `:8006`; `ApplyServerRunRate` call `:4373`; + JumpAction block `:7984-8002`; outbound RawMotionState build + `:7893-7913`; `EnterPlayerModeNow` binding block `:12987-13000`; + `EnsureRemoteMotionBindings` `:4195-4224`; `OnLiveVectorUpdated` + LeaveGround pattern `:4845-4864`. +- `src/AcDream.Core/Physics/MotionInterpreter.cs` — full file read, 3083 + lines; key anchors: `DoMotion` `:856-949`; `StopMotion` `:958-1008`; + `StopCompletely` `:1054-1084`; `apply_raw_movement` (2 overloads) + `:1346-1371`, `:1443-1450`; `apply_current_movement` `:1404-1417`; + `DefaultSink` property `:1489`; `ApplyCurrentMovementInterpreted` + `:1491-1536`; `ChargeJump` `:1765-1789`; `jump` `:1822-1839`; + `LeaveGround` `:2310-2330`; `HitGround` `:2361-2375`; + `EnterDefaultState` `:2415-2427`; `set_hold_run` `:2453-2463`; + `SetHoldKey` `:2506-2525`; `MotionDone` `:2132-2154`; + `ApplyInterpretedMovement` `:2715-2762`; `DoInterpretedMotion` + `:2837-2948`. +- `src/AcDream.Core/Physics/RawMotionState.cs` — full file, `ApplyMotion` + `:208-273`, `RemoveMotion` `:293-321`. +- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — full file, + fields `:84-186`.