diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ae0c01ac..f182d51a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -200,7 +200,6 @@ accepted-divergence entries (#96, #49, #50). | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | | TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | -| TS-22 | `adjust_motion` not ported — backward (×−0.65) / strafe (×−1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 | | TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | | TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | @@ -212,10 +211,11 @@ accepted-divergence entries (#96, #49, #50). | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | | TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 | +| TS-34 | `adjust_motion`'s creature guard is a no-op — retail returns early for a non-creature weenie (`!IsCreature()`), but acdream's `IWeenieObject` has no `IsCreature()`, so the guard always proceeds | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`adjust_motion`) | Correct today: the only caller of the local `adjust_motion`/`apply_raw_movement` path is the player (a creature); no non-creature drives it | If a non-creature weenie is ever routed through `adjust_motion` (e.g. a missile with a weenie), its motion would be wrongly normalized instead of skipped | `CMotionInterp::adjust_motion` 0x00528010 creature guard; add `IsCreature` to IWeenieObject | --- -## 5. Unclear (UN) — 5 rows +## 5. Unclear (UN) — 4 rows These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -226,7 +226,6 @@ equivalence argument (promote to AD/AP) or a fix. | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | | UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | -| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | @@ -242,17 +241,15 @@ WITH that phase, not before. 1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj. 2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. 3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. -4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it. -5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). -6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. -7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity. -8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. -9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. -10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. -13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. -14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). +5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. +6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. +7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. +8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. +9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. +10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. +11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. +12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating), diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1c47dd23..f5f21abb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -616,7 +616,17 @@ change must keep both phase plans in sync. `AceModernCommandCatalog` runtime default + full-extraction `Retail2013CommandCatalog` conformance; deleted the blind 0x016E–0x0197 override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug - as ISSUES #159. Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.) + as ISSUES #159. Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)* + *(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` / + `apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and + unified the local velocity + keyboard turn + jump onto one input-built + `RawMotionState` → `get_state_velocity` — backward/strafe-left no longer zero, + strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from + interpreted `turn_speed`, hand-mirrored formulas deleted. Retired register + TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to + the retail-exact 3.11999989f. Pseudocode: + `docs/research/2026-07-01-d6-motion-interp-pseudocode.md`. NEXT D6.2b: the + echo-gated wire `forward_speed=1.0` question.) - **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath style transitions, attack swings by stance/power/height, hit reactions, evades/blocks/parries, and death handoff. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index b6923fb9..b4df15b1 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -875,6 +875,40 @@ public sealed class PlayerMovementController TurnSpeed: null); } + // ── D6.2: normalize input into the interpreted motion state ─────────── + // ONE RawMotionState from MovementInput drives the local velocity + turn + // (retail CMotionInterp::apply_raw_movement 0x005287e0). RAW speeds are + // 1.0 — apply_run_to_command applies the run rate, so passing a pre-scaled + // speed would double-scale. adjust_motion normalizes backward → + // WalkForward (×-0.65) and strafe-left → SideStepRight (×-1×1.248), so + // get_state_velocity below is correct for ALL directions (retires the + // hand-mirrored formulas; register TS-22). Skipped during server + // auto-walk, which drives the body itself. + if (!autoWalkConsumedMotion) + { + var axisHold = input.Run ? HoldKey.Run : HoldKey.None; + var raw = new RawMotionState + { + CurrentHoldKey = axisHold, + ForwardCommand = input.Forward ? MotionCommand.WalkForward + : input.Backward ? MotionCommand.WalkBackward + : MotionCommand.Ready, + ForwardHoldKey = (input.Forward || input.Backward) ? axisHold : HoldKey.Invalid, + ForwardSpeed = 1.0f, + SidestepCommand = input.StrafeRight ? MotionCommand.SideStepRight + : input.StrafeLeft ? MotionCommand.SideStepLeft + : 0u, + SidestepHoldKey = (input.StrafeRight || input.StrafeLeft) ? axisHold : HoldKey.Invalid, + SidestepSpeed = 1.0f, + TurnCommand = input.TurnRight ? MotionCommand.TurnRight + : input.TurnLeft ? MotionCommand.TurnLeft + : 0u, + TurnHoldKey = (input.TurnRight || input.TurnLeft) ? axisHold : HoldKey.Invalid, + TurnSpeed = 1.0f, + }; + _motion.apply_raw_movement(raw); + } + // ── 1. Apply turning from keyboard + mouse ──────────────────────────── // 2026-05-16 — retail-faithful turn rate. // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt @@ -886,11 +920,21 @@ public sealed class PlayerMovementController // Effective: walking ≈ 90°/s, running ≈ 135°/s. // Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally // close to retail walking but no run differentiation. - float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run); - if (input.TurnRight) - Yaw -= keyboardTurnRate * dt; - if (input.TurnLeft) - Yaw += keyboardTurnRate * dt; + // D6.2: local keyboard turn rate now comes from the interpreted turn + // state (adjust_motion normalized TurnLeft→TurnRight with sign + + // apply_run_to_command ×RunTurnFactor when running). omega.Z = + // BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote + // path uses (GameWindow ObservedOmega seed). Numerically identical to the + // former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged. + // Skipped during auto-walk (server drives the turn). Mouse turn stays a + // direct Yaw delta (deliberately generates no turn command — avoids + // MoveToState spam from mouse jitter). + if (!autoWalkConsumedMotion + && _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) + { + Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec + * _motion.InterpretedState.TurnSpeed * dt; + } Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; @@ -910,53 +954,10 @@ public sealed class PlayerMovementController // (Ready/Stand) velocity, freezing the body. if (!autoWalkConsumedMotion) { - // Determine the dominant forward/backward command and speed. - uint forwardCmd; - float forwardCmdSpeed; - if (input.Forward) - { - forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward; - // When running, use the PlayerWeenie's RunRate as ForwardSpeed. - // The retail server computes this from Run skill + encumbrance and - // broadcasts it in UpdateMotion, but it doesn't echo to the sender. - // We compute locally using the same formula. - if (input.Run && _weenie.InqRunRate(out float runRate)) - forwardCmdSpeed = runRate; - else - forwardCmdSpeed = 1.0f; - } - else if (input.Backward) - { - forwardCmd = MotionCommand.WalkBackward; - // K-fix3 (2026-04-26): backward also honors Run. Without - // this, holding X with Run=true (default) still produced - // walk-tier backward speed because forwardCmdSpeed was - // hardcoded to 1.0. Now scale by runRate the same way - // RunForward does. - if (input.Run && _weenie.InqRunRate(out float runRateBack)) - forwardCmdSpeed = runRateBack; - else - forwardCmdSpeed = 1.0f; - } - else - { - forwardCmd = MotionCommand.Ready; - forwardCmdSpeed = 1.0f; - } - - // Update interpreted motion state (needed for animation + server messages). - _motion.DoMotion(forwardCmd, forwardCmdSpeed); - - // Sidestep. - if (input.StrafeRight) - _motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true); - else if (input.StrafeLeft) - _motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true); - else - { - _motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true); - _motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true); - } + // D6.2: the forward/sidestep command determination + DoMotion + + // DoInterpretedMotion moved into the apply_raw_movement call above, so + // the interpreted state (and thus get_state_velocity) is already + // populated + normalized for all directions. // Only replace velocity with motion interpreter output when grounded. // While airborne, the physics body's integrated velocity (from LeaveGround) @@ -965,37 +966,13 @@ public sealed class PlayerMovementController if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; + // D6.2: velocity for ALL directions comes from the normalized + // interpreted state — backward (WalkForward ×-0.65) and strafe-left + // (SideStepRight ×-1×1.248) are handled inside get_state_velocity now + // that adjust_motion ran above. The hand-mirrored formulas are gone + // (register TS-22 retired). var stateVel = _motion.get_state_velocity(); - - float localY = 0f; - float localX = 0f; - - // K-fix3 (2026-04-26): unified run-multiplier for backward - // + strafe. Forward already scales correctly because it uses - // stateVel.Y (which the motion state machine fed runRate - // into via DoMotion). Backward + strafe bypass the state - // machine and hardcoded speed; previously they capped at - // walk speed regardless of Run, which made the ~2.4× - // forward-vs-back/strafe ratio feel wrong. Now both scale - // with the same runRate the forward branch uses. - float runMul = 1.0f; - if (input.Run && _weenie.InqRunRate(out float vrr)) - runMul = vrr; - - if (input.Forward) - localY = stateVel.Y; - else if (input.Backward) - localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul); - - // Strafe scales with the same runMul so sidestep matches - // the forward pace at run speed (retail uses speed=1.0 for - // SideStep + the same hold-key-driven run/walk multiplier). - if (input.StrafeRight) - localX = MotionInterpreter.SidestepAnimSpeed * runMul; - else if (input.StrafeLeft) - localX = -MotionInterpreter.SidestepAnimSpeed * runMul; - - _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); + _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz)); } } // end of `if (!autoWalkConsumedMotion)` — section 2 @@ -1029,56 +1006,13 @@ public sealed class PlayerMovementController float jumpVz = _motion.get_jump_v_z(); _motion.LeaveGround(); outJumpExtent = _jumpExtent; - // BODY-LOCAL jump-launch velocity, computed directly from input. - // - // Why not read _body.Velocity? Because _motion.LeaveGround() - // routes through get_leave_ground_velocity → get_state_velocity, - // which is a faithful port of retail's FUN_00528960. Retail's - // version only handles WalkForward (0x45000005) / RunForward - // (0x44000007) / SideStepRight (0x6500000F); WalkBackwards - // and SideStepLeft return zero. Retail papers over this in - // adjust_motion (FUN_00528010) by translating - // WalkBackwards → WalkForward + speed × -0.65 - // SideStepLeft → SideStepRight + speed × -1 - // before they reach InterpretedState — but we don't yet port - // adjust_motion, so InterpretedState holds the un-translated - // command and get_state_velocity returns (0,0,0) for it. - // LeaveGround then writes (0,0,jumpZ) to the body, wiping the - // correct strafe/backward velocity the controller had just set - // a few lines up. Result: backward/strafe jumps go straight up. - // - // Until adjust_motion is ported, we mirror the grounded-velocity - // computation from the block above and stuff the result into - // outJumpVelocity directly. Local frame: +Y forward, +X right, - // +Z up — matches retail's body-frame convention. Server - // rotates body→world on receive, so observers see the jump - // in the correct world direction. - float jumpRunMul = 1.0f; - if (input.Run && _weenie.InqRunRate(out float jvrr)) - jumpRunMul = jvrr; - - // Forward uses get_state_velocity (which knows Walk vs Run vs - // animation-cycle pacing). Backward / Strafe use the same - // hardcoded scaled formulas the grounded-velocity block above - // uses (lines 397-408). - float localY = 0f; - if (input.Forward) - { - var stateVel = _motion.get_state_velocity(); - localY = stateVel.Y; - } - else if (input.Backward) - { - localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul); - } - - float localX = 0f; - if (input.StrafeRight) - localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul; - else if (input.StrafeLeft) - localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul; - - outJumpVelocity = new Vector3(localX, localY, jumpVz); + // D6.2: get_state_velocity() is now correct for all directions + // (apply_raw_movement normalized backward/strafe above), so the + // jump-launch velocity is get_state_velocity() + the vertical jump + // component. Retires the duplicated hand-mirrored formulas that + // existed only until adjust_motion was ported (register TS-22). + var jumpVel = _motion.get_state_velocity(); + outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz); // Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ) // to the body for backward/strafe-left (same get_state_velocity diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index cffb09fb..ddb278e7 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -251,8 +251,10 @@ public interface IWeenieObject public sealed class MotionInterpreter { // ── animation speed constants (from ACE / confirmed by decompile globals) ─ - /// Walk animation base speed (_DAT_007c96e4 family). - public const float WalkAnimSpeed = 3.12f; + /// Walk animation base speed. Retail-exact (.rdata 0x007c891c = + /// 3.11999989f); unified for both and + /// 's sidestep scale in D6.2. + public const float WalkAnimSpeed = 3.11999989f; /// Run animation base speed (_DAT_007c96e0 family). public const float RunAnimSpeed = 4.0f; /// Sidestep animation base speed (_DAT_007c96e8 family). @@ -264,6 +266,34 @@ public sealed class MotionInterpreter /// Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f). public const float MaxJumpExtent = 1.0f; + /// + /// Backward-speed multiplier applied by when + /// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910). + /// Retail-exact value; do not round to 0.65f. + /// + public const float BackwardsFactor = 0.649999976f; + + /// + /// Turn speed multiplier applied by for + /// TurnRight when the hold key is Run (retail apply_run_to_command, no + /// run-rate scaling and no clamp — just a flat ×1.5). + /// + public const float RunTurnFactor = 1.5f; + + /// + /// Symmetric clamp on sidestep interpreted speed applied by + /// for SideStepRight when running + /// (retail apply_run_to_command SideStepRight clamp, ±3.0). + /// + public const float MaxSidestepAnimRate = 3.0f; + + /// + /// Retail-exact sidestep scale factor used by + /// when remapping to SideStepRight (retail SidestepFactor = 0.5). + /// + public const float SidestepFactor = 0.5f; + + // ── fields (matching struct layout from acclient_function_map.md) ───────── /// The physics body this interpreter controls (struct offset +0x08). @@ -284,6 +314,16 @@ public sealed class MotionInterpreter /// Stored run rate from last successful InqRunRate call (offset +0x7C). public float MyRunRate = 1.0f; + /// + /// The physics object's currently-held movement key (retail + /// this->raw_state.current_holdkey). + /// falls back to this field when the per-channel holdKey argument passed + /// in is . D6.2 (the raw-state orchestrator) + /// is responsible for keeping this in sync with the raw motion state; + /// default is a safe no-op until then. + /// + public HoldKey CurrentHoldKey = HoldKey.None; + /// True when crouching-in-place for a standing long jump (offset +0x70). public bool StandingLongJump; @@ -647,6 +687,191 @@ public sealed class MotionInterpreter return velocity; } + // ── FUN_00528010 — adjust_motion ────────────────────────────────────────── + + /// + /// Normalize one raw motion channel (forward, sidestep, or turn) into its + /// canonical interpreted form: fold the "negative" commands (WalkBackward, + /// TurnLeft, SideStepLeft) into their positive counterpart with a negated/ + /// scaled speed, then apply the Run hold-key promotion. + /// + /// + /// Decompiled logic (FUN_00528010, docs/research/named-retail/ + /// acclient_2013_pseudo_c.txt:305343-305400): + /// + /// if weenie != null and !weenie.IsCreature(): return + /// switch cmd: + /// RunForward: return // no scale, NO holdkey path + /// WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor + /// TurnLeft: cmd = TurnRight; speed *= -1 + /// SideStepLeft: cmd = SideStepRight; speed *= -1 + /// if cmd == SideStepRight: + /// speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // ≈ 1.24799995 + /// if holdKey == Invalid: holdKey = raw.current_holdkey + /// if holdKey == Run: apply_run_to_command(ref cmd, ref speed) + /// + /// + /// + /// + /// GOTCHA: RunForward early-returns before the sidestep scale AND before + /// the hold-key path — a running forward command is already normalized + /// and must not be touched again. The sidestep negate (SideStepLeft → + /// SideStepRight, ×-1) happens BEFORE the ×1.248 anim-rate scale, so the + /// net multiplier for SideStepLeft is -1.248×speed, not -1×(1.248×speed) + /// applied out of order (same result here, but the ORDER matters once + /// apply_run_to_command's sign-dependent clamp is layered on top). + /// + /// + /// + /// Creature guard (retail weenie.IsCreature()) is UNWIRED. + /// does not currently expose an + /// IsCreature query, so this port always proceeds (equivalent to + /// "no weenie" or "is a creature" — true for the player, which is the + /// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject + /// extension; do not fabricate a guess at the query here. + /// + /// + /// Raw motion command, normalized in place. + /// Raw motion speed, normalized in place. + /// + /// The hold key for THIS channel (forward/sidestep/turn each carry their + /// own hold key in the retail raw state). Pass + /// to fall back to . + /// + public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey) + { + // Creature guard unwired — IWeenieObject has no IsCreature(); always proceed. + + switch (motion) + { + case MotionCommand.RunForward: + // Already normalized; no scale, no hold-key path. + return; + case MotionCommand.WalkBackward: + motion = MotionCommand.WalkForward; + speed *= -BackwardsFactor; + break; + case MotionCommand.TurnLeft: + motion = MotionCommand.TurnRight; + speed *= -1f; + break; + case MotionCommand.SideStepLeft: + motion = MotionCommand.SideStepRight; + speed *= -1f; + break; + } + + if (motion == MotionCommand.SideStepRight) + speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); + + if (holdKey == HoldKey.Invalid) + holdKey = CurrentHoldKey; + + if (holdKey == HoldKey.Run) + apply_run_to_command(ref motion, ref speed); + } + + // ── FUN_00527be0 — apply_run_to_command ─────────────────────────────────── + + /// + /// Apply the Run hold-key promotion/scale to an already-normalized + /// (positive-form) motion command. + /// + /// + /// Decompiled logic (FUN_00527be0, docs/research/named-retail/ + /// acclient_2013_pseudo_c.txt:305062-305123): + /// + /// speedMod = 1.0 + /// if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate + /// switch cmd: + /// WalkForward: + /// if speed > 0: cmd = RunForward // promote ONLY when moving forward + /// speed *= speedMod // UNCONDITIONAL — applies to backward too + /// TurnRight: + /// speed *= RunTurnFactor // ×1.5, no run-rate, no clamp + /// SideStepRight: + /// speed *= speedMod + /// if abs(speed) > MaxSidestepAnimRate: speed = speed > 0 ? Max : -Max + /// + /// + /// + /// + /// GOTCHA: the WalkForward speed *= speedMod is unconditional — + /// it applies even when speed is negative (backward), even though the + /// command is NOT promoted to RunForward in that case (the promotion is + /// sign-gated, the scale is not). + /// + /// + public void apply_run_to_command(ref uint motion, ref float speed) + { + float speedMod = 1.0f; + if (WeenieObj is not null) + speedMod = WeenieObj.InqRunRate(out float rate) ? rate : MyRunRate; + + switch (motion) + { + case MotionCommand.WalkForward: + if (speed > 0f) + motion = MotionCommand.RunForward; + speed *= speedMod; + break; + case MotionCommand.TurnRight: + speed *= RunTurnFactor; + break; + case MotionCommand.SideStepRight: + speed *= speedMod; + if (MathF.Abs(speed) > MaxSidestepAnimRate) + speed = speed > 0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate; + break; + } + } + + // ── 0x005287e0 — apply_raw_movement ─────────────────────────────────────── + + /// + /// Retail CMotionInterp::apply_raw_movement orchestrator + /// (0x005287e0, docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834). + /// Copies the raw motion state into the interpreted state, then normalizes + /// each of the three channels (forward / sidestep / turn) IN PLACE via + /// using that channel's own hold key. + /// + /// + /// After this call, yields correct velocity + /// for ALL directions — backward (WalkBackward→WalkForward, ×-0.65) and + /// strafe-left (SideStepLeft→SideStepRight, ×-1×1.248) are normalized here, + /// which is the D6 fix that retires the hand-mirrored controller formulas + /// (register TS-22). Callers must pass RAW speeds (forward_speed = 1.0), NOT + /// pre-run-scaled speeds — applies the run + /// rate, so a pre-scaled input would double-scale. + /// + /// + public void apply_raw_movement(RawMotionState raw) + { + CurrentHoldKey = raw.CurrentHoldKey; + + // Copy raw -> interpreted. Retail copies 7 fields; our + // InterpretedMotionState omits current_style (velocity doesn't use it). + InterpretedState.ForwardCommand = raw.ForwardCommand; + InterpretedState.ForwardSpeed = raw.ForwardSpeed; + InterpretedState.SideStepCommand = raw.SidestepCommand; + InterpretedState.SideStepSpeed = raw.SidestepSpeed; + InterpretedState.TurnCommand = raw.TurnCommand; + InterpretedState.TurnSpeed = raw.TurnSpeed; + + // Normalize each channel with its OWN per-channel hold key. + uint fCmd = InterpretedState.ForwardCommand; float fSpd = InterpretedState.ForwardSpeed; + adjust_motion(ref fCmd, ref fSpd, raw.ForwardHoldKey); + InterpretedState.ForwardCommand = fCmd; InterpretedState.ForwardSpeed = fSpd; + + uint sCmd = InterpretedState.SideStepCommand; float sSpd = InterpretedState.SideStepSpeed; + adjust_motion(ref sCmd, ref sSpd, raw.SidestepHoldKey); + InterpretedState.SideStepCommand = sCmd; InterpretedState.SideStepSpeed = sSpd; + + uint tCmd = InterpretedState.TurnCommand; float tSpd = InterpretedState.TurnSpeed; + adjust_motion(ref tCmd, ref tSpd, raw.TurnHoldKey); + InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd; + } + // ── FUN_00529210 — apply_current_movement ───────────────────────────────── /// diff --git a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs new file mode 100644 index 00000000..0887c844 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs @@ -0,0 +1,299 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// MotionNormalizationTests — Phase D6.1: adjust_motion + apply_run_to_command. +// +// Source addresses tested (docs/research/2026-07-01-d6-motion-interp-pseudocode.md): +// FUN_00528010 (0x00528010) CMotionInterp::adjust_motion +// FUN_00527be0 (0x00527be0) CMotionInterp::apply_run_to_command +// +// Golden constants (retail, verified against ACE MotionInterp.cs byte-for-byte): +// BackwardsFactor = 0.649999976 (0x007c8910) +// WalkAnimSpeed = 3.11999989 (0x007c891c, retail-exact — NOT 3.12f) +// SidestepAnimSpeed = 1.25 +// SidestepFactor = 0.5 +// RunTurnFactor = 1.5 +// MaxSidestepAnimRate = 3.0 +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Fake WeenieObject for injecting a known run rate into apply_run_to_command / +/// adjust_motion without a real weenie. +/// +file sealed class FakeRunRateWeenie : IWeenieObject +{ + public float RunRate; + public bool InqRunRateResult = true; + + public bool InqJumpVelocity(float extent, out float vz) + { + vz = 0f; + return false; + } + + public bool InqRunRate(out float rate) + { + rate = RunRate; + return InqRunRateResult; + } + + public bool CanJump(float extent) => true; +} + +public sealed class MotionNormalizationTests +{ + // Retail-exact sidestep scale: SidestepFactor(0.5) * (3.11999989 / 1.25) ≈ 1.24799995... + private const float SidestepScale = 0.5f * (3.11999989f / MotionInterpreter.SidestepAnimSpeed); + + private static MotionInterpreter MakeInterp(IWeenieObject? weenie = null) + { + return new MotionInterpreter { WeenieObj = weenie }; + } + + // ── adjust_motion: per-command remap + scale ───────────────────────────── + + [Fact] + public void AdjustMotion_WalkBackward_RemapsToWalkForward_NegatesAndScalesSpeed() + { + // 0x00528010: WalkBackwards -> cmd = WalkForward; speed *= -BackwardsFactor + var interp = MakeInterp(); + uint motion = MotionCommand.WalkBackward; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, 5); + Assert.Equal(-0.649999976f, speed, 5); + } + + [Fact] + public void AdjustMotion_TurnLeft_RemapsToTurnRight_NegatesSpeed() + { + var interp = MakeInterp(); + uint motion = MotionCommand.TurnLeft; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.TurnRight, motion); + Assert.Equal(-1.0f, speed, 5); + } + + [Fact] + public void AdjustMotion_SideStepLeft_RemapsToSideStepRight_NegatesBeforeScale() + { + // Negate happens BEFORE the ×1.248 sidestep scale, so the net factor is -1.248. + var interp = MakeInterp(); + uint motion = MotionCommand.SideStepLeft; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.SideStepRight, motion); + Assert.Equal(-SidestepScale, speed, 5); + Assert.Equal(-1.24799995f, speed, 4); + } + + [Fact] + public void AdjustMotion_SideStepRight_ScalesSpeed_NoNegate() + { + var interp = MakeInterp(); + uint motion = MotionCommand.SideStepRight; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.SideStepRight, motion); + Assert.Equal(SidestepScale, speed, 5); + Assert.Equal(1.24799995f, speed, 4); + } + + [Fact] + public void AdjustMotion_RunForward_EarlyReturns_NoScaleNoHoldKeyPath() + { + // GOTCHA: RunForward returns immediately -- no scale AND no holdkey promotion, + // even when holdKey == Run (there's nothing left to promote it to, but the + // early-return also means speed is untouched). + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.RunForward; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.Run); + + Assert.Equal(MotionCommand.RunForward, motion); + Assert.Equal(1.0f, speed, 5); + } + + [Fact] + public void AdjustMotion_NonSidestepNonTurnNonForward_Unchanged_ExceptHoldKeyPath() + { + // Ready is not one of the switch cases and not SideStepRight after remap, + // so command/speed pass through unchanged when holdKey doesn't promote it. + var interp = MakeInterp(); + uint motion = MotionCommand.Ready; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.Ready, motion); + Assert.Equal(1.0f, speed, 5); + } + + // ── adjust_motion: holdKey inheritance + Run promotion ─────────────────── + + [Fact] + public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun() + { + var interp = MakeInterp(); + interp.CurrentHoldKey = HoldKey.Run; + uint motion = MotionCommand.WalkForward; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid); + + // apply_run_to_command: WalkForward + speed>0 -> RunForward; speed *= speedMod (1.0, no weenie). + Assert.Equal(MotionCommand.RunForward, motion); + Assert.Equal(1.0f, speed, 5); + } + + [Fact] + public void AdjustMotion_HoldKeyNone_DoesNotPromote() + { + var interp = MakeInterp(); + interp.CurrentHoldKey = HoldKey.Run; + uint motion = MotionCommand.WalkForward; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.None); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.Equal(1.0f, speed, 5); + } + + [Fact] + public void AdjustMotion_HoldKeyRunPassedDirectly_PromotesWalkForward() + { + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.75f }); + uint motion = MotionCommand.WalkForward; + float speed = 1.0f; + + interp.adjust_motion(ref motion, ref speed, HoldKey.Run); + + Assert.Equal(MotionCommand.RunForward, motion); + Assert.Equal(2.75f, speed, 5); + } + + // ── apply_run_to_command ────────────────────────────────────────────────── + + [Fact] + public void ApplyRunToCommand_WalkForward_PositiveSpeed_PromotesToRunForward_ScalesByRunRate() + { + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.WalkForward; + float speed = 1.0f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.RunForward, motion); + Assert.Equal(2.94f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_WalkForward_NegativeSpeed_StaysWalkForward_ScalesUnconditionally() + { + // GOTCHA: speed *= speedMod is UNCONDITIONAL -- backward (negative speed) + // still gets run-scaled even though it is NOT promoted to RunForward. + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.WalkForward; + float speed = -0.649999976f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.Equal(-0.649999976f * 2.94f, speed, 4); + } + + [Fact] + public void ApplyRunToCommand_TurnRight_ScalesByRunTurnFactor_NoRunRateNoClamp() + { + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.TurnRight; + float speed = 1.0f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.TurnRight, motion); + Assert.Equal(1.5f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_SideStepRight_BelowClamp_ScalesByRunRate() + { + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.0f }); + uint motion = MotionCommand.SideStepRight; + float speed = 1.0f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.SideStepRight, motion); + Assert.Equal(2.0f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_SideStepRight_AbovePositiveClamp_ClampsToPositiveMax() + { + // runRate 2.94 * speed 1.248 (post adjust_motion sidestep scale) ≈ 3.669 > 3.0 + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.SideStepRight; + float speed = 1.24799995f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.SideStepRight, motion); + Assert.Equal(3.0f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_SideStepRight_AboveNegativeClamp_ClampsToNegativeMax() + { + var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); + uint motion = MotionCommand.SideStepRight; + float speed = -1.24799995f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.SideStepRight, motion); + Assert.Equal(-3.0f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_UsesMyRunRate_WhenInqRunRateFails() + { + var weenie = new FakeRunRateWeenie { RunRate = 999f, InqRunRateResult = false }; + var interp = MakeInterp(weenie); + interp.MyRunRate = 1.5f; + uint motion = MotionCommand.WalkForward; + float speed = 1.0f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(MotionCommand.RunForward, motion); + Assert.Equal(1.5f, speed, 5); + } + + [Fact] + public void ApplyRunToCommand_NoWeenie_SpeedModDefaultsToOne() + { + var interp = MakeInterp(weenie: null); + uint motion = MotionCommand.SideStepRight; + float speed = 1.0f; + + interp.apply_run_to_command(ref motion, ref speed); + + Assert.Equal(1.0f, speed, 5); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs new file mode 100644 index 00000000..45c71889 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs @@ -0,0 +1,187 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +// ───────────────────────────────────────────────────────────────────────────── +// MotionVelocityPipelineTests — Phase D6.2: the full raw→interpreted→velocity +// pipeline (apply_raw_movement 0x005287e0 → get_state_velocity 0x00527d50). +// +// These pin the retail-faithful LOCAL velocity for every direction — the exact +// behavior the D6.2 controller integration now routes through, replacing the +// hand-mirrored backward/strafe formulas (register TS-22). Golden values are +// derived from the pseudocode doc +// (docs/research/2026-07-01-d6-motion-interp-pseudocode.md) and the retail +// constants on MotionInterpreter. +// +// Notable retail-faithful change vs the old hand-mirror: strafe is now +// 1.25 × (0.5·WalkAnimSpeed/SidestepAnimSpeed) × runRate = ~1.56 × runRate, +// clamped via SideStepSpeed ≤ 3.0 (so |v.X| ≤ 3.75). The old code used +// 1.25 × runRate with no sidestep-scale and no clamp. +// ───────────────────────────────────────────────────────────────────────────── + +file sealed class FakeRunRateWeenie : IWeenieObject +{ + public float RunRate; + public bool InqRunRateResult = true; + public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; } + public bool InqRunRate(out float rate) { rate = RunRate; return InqRunRateResult; } + public bool CanJump(float extent) => true; +} + +public sealed class MotionVelocityPipelineTests +{ + private const float RunRate = 2.75f; // ≈ the +Acdream test char's run rate + + private const float WalkAnim = MotionInterpreter.WalkAnimSpeed; // 3.11999989 + private const float RunAnim = MotionInterpreter.RunAnimSpeed; // 4.0 + private const float SideAnim = MotionInterpreter.SidestepAnimSpeed; // 1.25 + private const float BackFac = MotionInterpreter.BackwardsFactor; // 0.649999976 + private const float MaxSide = MotionInterpreter.MaxSidestepAnimRate;// 3.0 + // adjust_motion sidestep scale: 0.5 * (WalkAnim / SideAnim) ≈ 1.24799995 + private const float SideScale = MotionInterpreter.SidestepFactor * (WalkAnim / SideAnim); + + private static MotionInterpreter MakeInterp(float runRate) + => new() { WeenieObj = new FakeRunRateWeenie { RunRate = runRate } }; + + private static RawMotionState Raw( + uint forward = MotionCommand.Ready, uint sidestep = 0u, uint turn = 0u, + HoldKey hold = HoldKey.None) + => new() + { + CurrentHoldKey = hold, + ForwardCommand = forward, + ForwardHoldKey = forward != MotionCommand.Ready ? hold : HoldKey.Invalid, + ForwardSpeed = 1.0f, + SidestepCommand = sidestep, + SidestepHoldKey = sidestep != 0u ? hold : HoldKey.Invalid, + SidestepSpeed = 1.0f, + TurnCommand = turn, + TurnHoldKey = turn != 0u ? hold : HoldKey.Invalid, + TurnSpeed = 1.0f, + }; + + // ── forward ────────────────────────────────────────────────────────────── + + [Fact] + public void RunForward_VelocityIsRunAnimSpeedTimesRunRate() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.Run)); + + // WalkForward + Run → RunForward @ runRate; v.Y = RunAnim * runRate (== maxSpeed, no clamp). + var v = interp.get_state_velocity(); + Assert.Equal(RunAnim * RunRate, v.Y, 3); + Assert.Equal(0f, v.X, 5); + } + + [Fact] + public void WalkForward_NoRun_VelocityIsWalkAnimSpeed() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.None)); + + var v = interp.get_state_velocity(); + Assert.Equal(WalkAnim * 1.0f, v.Y, 3); // no run scaling; WalkForward @ 1.0 + } + + // ── backward (the TS-22 fix: no longer zero) ───────────────────────────── + + [Fact] + public void RunBackward_VelocityIsNegativeWalkTimesBackwardFactorTimesRunRate() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(forward: MotionCommand.WalkBackward, hold: HoldKey.Run)); + + // WalkBackward → WalkForward, speed *= -0.65; Run → *= runRate (unconditional, + // promotion sign-gated so it stays WalkForward). v.Y = WalkAnim * (-0.65 * runRate). + var v = interp.get_state_velocity(); + Assert.Equal(-(WalkAnim * BackFac * RunRate), v.Y, 3); + Assert.True(v.Y < 0f, "backward velocity must be negative"); + // Matches the OLD hand-mirror (WalkAnim * 0.65 * runMul) — backward is unchanged. + } + + // ── strafe (retail-faithful magnitude + ±3.0 clamp) ────────────────────── + + [Fact] + public void RunStrafeRight_ClampsSideStepSpeedToThree_VelocityIs3p75() + { + var interp = MakeInterp(RunRate); // 1.248*2.75 = 3.432 > 3.0 → clamps + interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); + + var v = interp.get_state_velocity(); + Assert.Equal(SideAnim * MaxSide, v.X, 3); // 1.25 * 3.0 = 3.75 + Assert.Equal(3.75f, v.X, 3); + Assert.Equal(0f, v.Y, 5); + } + + [Fact] + public void RunStrafeRight_BelowClamp_VelocityIsScaledSidestep() + { + const float lowRun = 2.0f; // 1.248*2.0 = 2.496 < 3.0 → no clamp + var interp = MakeInterp(lowRun); + interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); + + var v = interp.get_state_velocity(); + // v.X = SideAnim * (SideScale * lowRun) = 1.56 * lowRun ≈ 3.12 + Assert.Equal(SideAnim * SideScale * lowRun, v.X, 3); + } + + [Fact] + public void RunStrafeLeft_NegatedAndClamped_VelocityIsNeg3p75() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepLeft, hold: HoldKey.Run)); + + // SideStepLeft → SideStepRight, negated → -1.248; *runRate = -3.432; clamp → -3.0. + var v = interp.get_state_velocity(); + Assert.Equal(-(SideAnim * MaxSide), v.X, 3); // -3.75 + Assert.True(v.X < 0f, "strafe-left velocity must be negative"); + } + + // ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ─────────────── + + [Fact] + public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.Run)); + + Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); + Assert.Equal(MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // +1.5 + } + + [Fact] + public void RunTurnLeft_RemapsToTurnRightWithNegativeRunTurnFactor() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(turn: MotionCommand.TurnLeft, hold: HoldKey.Run)); + + // TurnLeft → TurnRight, speed *= -1; Run → *= 1.5 → -1.5. + Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); + Assert.Equal(-MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // -1.5 + } + + [Fact] + public void WalkTurn_NoRun_TurnSpeedIsUnity() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.None)); + + Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); + Assert.Equal(1.0f, interp.InterpretedState.TurnSpeed, 5); // no run factor + } + + // ── idle ───────────────────────────────────────────────────────────────── + + [Fact] + public void Idle_ReadyState_ZeroVelocity() + { + var interp = MakeInterp(RunRate); + interp.apply_raw_movement(Raw()); // Ready, no sidestep/turn + + var v = interp.get_state_velocity(); + Assert.Equal(0f, v.X, 5); + Assert.Equal(0f, v.Y, 5); + } +}