acdream/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md
Erik fc5a2cda28 docs(R3-W6a): local-player cutover map — re-anchored edit list + edge table + risk decisions
Five discoveries beyond the plan text: ChargeJump never called by
production code (StandingLongJump has never armed — W6 must wire the
charge-start call); the DefaultSink→sequencer path already exists
end-to-end (W6 = bind one property + delete UpdatePlayerAnimation +
the edge detector); skipTransitionLink already gone (stale plan
bullet); the #45 sidestep factor + ACDREAM_ANIM_SPEED_SCALE have no
equivalent in the shared sink path (decision required); ApplyServerRunRate's
apply_current_movement goes live once DefaultSink binds (fast re-speed
absorbs it — retail's own mid-run speed-change mechanism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:06:42 +02:00

41 KiB
Raw Blame History

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:

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:

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-1322localAnimCmd 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):

_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 MotionTableDispatchSinkDefaultSink 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:

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. DoMotionDoInterpretedMotion(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() == trueneeds 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):

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 = <that sink> 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 (ApplyInterpretedMovementDefaultSink.ApplyMotionMotionTableDispatchSink.ApplyMotionsequencer.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_velocityset_local_velocity write, no sequencer touch). Post-W6 (once DefaultSink is bound, §3b), the SAME call now ALSO dispatches through the funnel (ApplyInterpretedMovementSetCycle) 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 MovementInputUpdate 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.csUpdatePlayerAnimation :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.