diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 719e1af6..acf206a3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -209,7 +209,7 @@ accepted-divergence entries (#96, #49, #50). | 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-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-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still 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`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | 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 | | TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 | | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | diff --git a/src/AcDream.Core/Physics/Motion/MotionNode.cs b/src/AcDream.Core/Physics/Motion/MotionNode.cs new file mode 100644 index 00000000..007a6c11 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionNode.cs @@ -0,0 +1,30 @@ +namespace AcDream.Core.Physics.Motion; + +/// +/// R3-W1 — verbatim port of retail's CMotionInterp::MotionNode +/// (acclient.h:53293, struct #5857): +/// +/// struct __cppobj CMotionInterp::MotionNode : LListData +/// { +/// unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr) +/// unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag +/// unsigned int jump_error_code; // +0xc +/// }; +/// +/// This is the node type queued onto CMotionInterp::pending_motions +/// by add_to_queue (0x00527b80, docs/research/2026-07-02-r3-motioninterp/ +/// r3-motioninterp-decomp.md §1a) and consumed head-first, unconditionally, +/// by MotionDone (0x00527ec0, §1c) / HandleExitWorld (0x00527f30, +/// §1d). W1 ports only the shape — the queue itself (PendingMotions, +/// add_to_queue, MotionDone) is R3-W2 scope (r3-port-plan.md §3). +/// +/// Retail context_id (+4) — the +/// MovementParameters.ContextId that produced this node. +/// Retail motion (+8) — the applied motion id. +/// Bit 0x10000000 marks an "action-class" motion; MotionDone +/// only pops the state action-FIFO head when this bit is set on the queue +/// head it is consuming. +/// Retail jump_error_code (+0xc) — the +/// motion_allows_jump result computed at enqueue time. Peeked by +/// jump_is_allowed's pending-head short-circuit (W0-pins A2). +public readonly record struct MotionNode(uint ContextId, uint Motion, uint JumpErrorCode); diff --git a/src/AcDream.Core/Physics/Motion/MovementParameters.cs b/src/AcDream.Core/Physics/Motion/MovementParameters.cs new file mode 100644 index 00000000..798157dd --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MovementParameters.cs @@ -0,0 +1,187 @@ +namespace AcDream.Core.Physics.Motion; + +/// +/// R3-W1 — verbatim port of retail's MovementParameters +/// (acclient.h:31453, struct #3460; bitfield struct +/// acclient.h:31423-31443): +/// +/// struct __cppobj MovementParameters : PackObj +/// { +/// union { unsigned int bitfield; ... } ___u1; +/// float distance_to_object; +/// float min_distance; +/// float desired_heading; +/// float speed; +/// float fail_distance; +/// float walk_run_threshhold; +/// unsigned int context_id; +/// HoldKey hold_key_to_apply; +/// unsigned int action_stamp; +/// }; +/// +/// +/// +/// The bitfield's absolute mask table is pinned in +/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bit-for-bit +/// identical to ACE's MovementParamFlags): +/// +/// +/// 0x1CanWalk +/// 0x2CanRun +/// 0x4CanSidestep +/// 0x8CanWalkBackwards +/// 0x10CanCharge +/// 0x20FailWalk +/// 0x40UseFinalHeading +/// 0x80Sticky +/// 0x100MoveAway +/// 0x200MoveTowards +/// 0x400UseSpheres +/// 0x800SetHoldKey +/// 0x1000Autonomous +/// 0x2000ModifyRawState +/// 0x4000ModifyInterpretedState +/// 0x8000CancelMoveTo +/// 0x10000StopCompletely +/// 0x20000DisableJumpDuringLink +/// +/// +/// +/// Ctor default (raw 300510-300534, 0x00524380): +/// (bitfield & 0xfffdee0f) | 0x1ee0f0x1EE0F sets +/// {CanWalk, CanRun, CanSidestep, CanWalkBackwards, MoveTowards, UseSpheres, +/// SetHoldKey, ModifyRawState, ModifyInterpretedState, CancelMoveTo, +/// StopCompletely}; clears {CanCharge, FailWalk, UseFinalHeading, Sticky, +/// MoveAway, Autonomous, DisableJumpDuringLink}. +/// Scalars: min_distance=0, distance_to_object=0.6, +/// fail_distance=FLT_MAX, desired_heading=0, speed=1, +/// walk_run_threshhold=15 (NOT ACE's 1.0 — W0-pins A4 divergence trap), +/// context_id=0, hold_key_to_apply=HoldKey.Invalid, +/// action_stamp=0. +/// +/// +/// +/// ACE-divergence traps (W0-pins A4, do not copy): ACE's +/// MovementParameters ctor sets CanCharge = true +/// (MovementParameters.cs:58) — retail's default has bit 0x10 CLEAR; +/// this port defaults CanCharge = false. ACE also changed +/// Default_WalkRunThreshold to 1.0 (L50) vs retail's literal 15.0 +/// (@300519) — this port defaults WalkRunThreshhold = 15f. +/// +/// +/// +/// Named bool properties per plan (no ToBitfield()/FromBitfield() +/// pair — the wire never carries this struct raw; RawMotionState::Pack +/// serializes the STATE, not this transient parameter block. If a future +/// slice needs the packed form, add the pair then with a cited call site). +/// +/// +public sealed class MovementParameters +{ + // ── bitfield flags (retail 0x1EE0F default; W0-pins A4) ─────────────── + + /// Mask 0x1 — default true. + public bool CanWalk { get; set; } = true; + + /// Mask 0x2 — default true. + public bool CanRun { get; set; } = true; + + /// Mask 0x4 — default true. + public bool CanSidestep { get; set; } = true; + + /// Mask 0x8 — default true. + public bool CanWalkBackwards { get; set; } = true; + + /// + /// Mask 0x10 — default FALSE. ACE-divergence trap (W0-pins A4): ACE's + /// ctor sets this true (MovementParameters.cs:58); retail's 0x1EE0F + /// default has bit 0x10 CLEAR. Do not "fix" this to true. + /// + public bool CanCharge { get; set; } + + /// Mask 0x20 — default false. + public bool FailWalk { get; set; } + + /// Mask 0x40 — default false. + public bool UseFinalHeading { get; set; } + + /// Mask 0x80 — default false. + public bool Sticky { get; set; } + + /// Mask 0x100 — default false. + public bool MoveAway { get; set; } + + /// Mask 0x200 — default true. + public bool MoveTowards { get; set; } = true; + + /// Mask 0x400 — default true. + public bool UseSpheres { get; set; } = true; + + /// Mask 0x800 — default true. DoMotion @306188: byte1&8 + /// requests a SetHoldKey call before adjust_motion. + public bool SetHoldKey { get; set; } = true; + + /// + /// Mask 0x1000 — default FALSE. Not the same virtual as + /// IWeenieObject.IsThePlayer (W0-pins A3) — this is the + /// per-call "was this an autonomous (locally-predicted) action?" flag. + /// + public bool Autonomous { get; set; } + + /// Mask 0x2000 — default true. DoMotion @306213: byte1&0x20 + /// mirrors the applied motion into RawMotionState via + /// ApplyMotion/RemoveMotion. + public bool ModifyRawState { get; set; } = true; + + /// Mask 0x4000 — default true. Mirrors into + /// InterpretedMotionState. + public bool ModifyInterpretedState { get; set; } = true; + + /// Mask 0x8000 — default true. Bitfield high-byte sign bit; + /// DoMotion/StopMotion @306183/@305684: triggers + /// interrupt_current_movement before the rest of the call. + public bool CancelMoveTo { get; set; } = true; + + /// Mask 0x10000 — default true. + public bool StopCompletelyFlag { get; set; } = true; + + /// + /// Mask 0x20000 — default FALSE. DoInterpretedMotion @305597: when set, + /// forces the computed jump_error_code to 0x48 (A1: jump + /// BLOCKED by motion/position) regardless of what + /// motion_allows_jump would have said. + /// + public bool DisableJumpDuringLink { get; set; } + + // ── scalar fields (retail ctor 0x00524380 defaults) ─────────────────── + + /// Retail default 0.6. + public float DistanceToObject { get; set; } = 0.6f; + + /// Retail default 0. + public float MinDistance { get; set; } + + /// Retail default 0. + public float DesiredHeading { get; set; } + + /// Retail default 1. + public float Speed { get; set; } = 1f; + + /// Retail default FLT_MAX. + public float FailDistance { get; set; } = float.MaxValue; + + /// + /// Retail default 15.0 (@300519). ACE-divergence trap (W0-pins A4): ACE + /// changed Default_WalkRunThreshold to 1.0 — do not copy. + /// + public float WalkRunThreshhold { get; set; } = 15f; + + /// Retail default 0. + public uint ContextId { get; set; } + + /// Retail default . + public HoldKey HoldKeyToApply { get; set; } = HoldKey.Invalid; + + /// Retail default 0. + public uint ActionStamp { get; set; } +} diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index eddfa868..c2be087f 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Physics.Motion; namespace AcDream.Core.Physics; @@ -107,81 +109,119 @@ public enum MovementType } /// -/// WeenieError codes returned by CMotionInterp methods. -/// Values are the hex constants used directly in the decompiled C code. +/// WeenieError-shaped codes returned by CMotionInterp methods. Values are +/// the hex constants used directly in the decompiled C code. +/// +/// +/// R3-W1 renumber (closes J16-codes/A10). Prior to this slice, the +/// names were shuffled relative to retail's actual numeric semantics +/// (GeneralMovementFailure was assigned 0x24 and used at the +/// airborne-jump gate, but retail's 0x24 means "not grounded / no contact" +/// and retail's 0x47 is the general-movement-failure code; 0x48 meant +/// "cannot jump while in the air" here, but retail's 0x48 means "jump +/// blocked by the CURRENT MOTION OR POSITION" — a blocklist check +/// (motion_allows_jump), not an airborne check). Renumbered per the +/// definitive, exhaustively-swept table in +/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (19 +/// return sites + 1 store site over raw 304908-306277 + 300150-300540). +/// These codes are LOCAL-ONLY — never serialized to the wire — so the +/// renumber is safe (verified: no consumer outside +/// MotionInterpreter.cs pattern-matches on the numeric value). +/// /// public enum WeenieError : uint { /// 0x00 — success. None = 0x00, - /// 0x08 — PhysicsObj is null. + /// + /// 0x08 — no physics_obj. Sites (A10): StopCompletely @305214; + /// DoInterpretedMotion @305579; StopInterpretedMotion @305639; + /// StopMotion @305680; jump @305798; DoMotion @306165. + /// NoPhysicsObject = 0x08, - /// 0x24 — general movement failure. - GeneralMovementFailure = 0x24, - /// 0x47 — cannot jump from this position (motion state blocks it). - YouCantJumpFromThisPosition = 0x47, - /// 0x48 — cannot jump while in the air. - YouCantJumpWhileInTheAir = 0x48, - /// 0x49 — loaded down / weenie blocked the jump. + /// + /// 0x24 — not grounded / no contact. Sites (A10): + /// jump_is_allowed @305570 (gravity-active creature without + /// Contact+OnWalkable; also the physics_obj == null case, which + /// falls out to this same code per the A10 note — NOT 8); + /// DoInterpretedMotion @305622-305623 (action-class motion + /// blocked by contact_allows_move). + /// + NotGrounded = 0x24, + /// + /// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site: + /// DoMotion @306196. + /// + CrouchInCombatStance = 0x3f, + /// + /// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site: + /// DoMotion @306199. + /// + SitInCombatStance = 0x40, + /// + /// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site: + /// DoMotion @306202. + /// + SleepInCombatStance = 0x41, + /// + /// 0x42 — motion & 0x2000000 (the chat-emote bit) rejected + /// outside NonCombat (0x8000003d). Site: DoMotion @306205. + /// + ChatEmoteOutsideNonCombat = 0x42, + /// + /// 0x45 — action-queue depth cap: an action-class motion (bit + /// 0x10000000) with GetNumActions() >= 6 pending. Site: + /// DoMotion @306209. + /// + ActionDepthExceeded = 0x45, + /// + /// 0x47 — general movement failure. Sites (A10): + /// jump_is_allowed @305525 (IsFullyConstrained); + /// @305549+305556 (JumpStaminaCost refusal); + /// CMotionInterp::PerformMovement @306227 (dispatch type-1 > 4); + /// MovementManager::PerformMovement @300201 (dispatch type-1 > 8). + /// + GeneralMovementFailure = 0x47, + /// + /// 0x48 — jump BLOCKED by the current motion or position (A1: the + /// motion_allows_jump literal-range blocklist — NOT an airborne + /// check; airborne is 0x24). Sites (A10): + /// motion_allows_jump @304930; jump_charge_is_allowed + /// @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 + /// (same predicate); STORED (not returned) as the queue node's + /// jump_error_code in DoInterpretedMotion @305605 when + /// disable_jump_during_link is set. + /// + YouCantJumpFromThisPosition = 0x48, + /// + /// 0x49 — the weenie's CanJump(jump_extent) virtual refused + /// (e.g. stamina/burden gate). Sites: jump_charge_is_allowed + /// @304941; charge_jump @305454. + /// CantJumpLoadedDown = 0x49, } // ── Motion state structs ─────────────────────────────────────────────────────── /// -/// Raw (network-derived) motion state for the local player, as consumed by -/// the (still-approximate) input-state path. -/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 = -/// ForwardCommand, +0x28 = ForwardSpeed, etc.). +/// Interpreted motion state, derived from the raw state (retail +/// InterpretedMotionState, ctor 0x0051e8d0, decomp 293418-293431). +/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50). /// /// -/// Renamed from RawMotionState during the L.2b wire-parity slice -/// (2026-06-30) to free that name for the retail-faithful, full-field -/// AcDream.Core.Physics.RawMotionState (mirrors -/// acclient.h RawMotionState::PackBitfield) used by the outbound -/// wire packer. This partial type is D6/Phase-2 territory (the -/// adjust_motion/apply_raw_movement input-state port) — -/// out of scope for this slice. It is expected to be folded into or -/// replaced by the retail-faithful type when D6 lands. +/// R3-W1 (closes J2): gains retail's action FIFO +/// (/// +/// /) — previously this was +/// a flat 6-field struct with no action tracking, so +/// DoMotion's GetNumActions() >= 6 depth cap and +/// MotionDone's action-class RemoveAction pop had nothing to +/// operate on. The action list is backed by a private +/// lazily created on first mutation (defensive +/// against a bare default(InterpretedMotionState), which C# structs +/// permit even though every constructor in this file routes through +/// ). /// /// -public struct LegacyRawMotionState -{ - /// Current style / stance (retail raw_state.current_style, - /// offset +0x18). Adopted from the inbound InterpretedMotionState by - /// move_to_interpreted_state (0x005289c0 line 305944). Default - /// NonCombat 0x8000003D. - public uint CurrentStyle; - /// Forward/backward motion command (offset +0x20). - public uint ForwardCommand; - /// Speed scalar for forward motion (offset +0x28). - public float ForwardSpeed; - /// Sidestep command (offset +0x2C). - public uint SideStepCommand; - /// Speed scalar for sidestep (offset +0x34, inferred from ACE). - public float SideStepSpeed; - /// Turn command (offset +0x38). - public uint TurnCommand; - /// Speed scalar for turn (offset +0x40, inferred). - public float TurnSpeed; - - /// Initialize to the idle/ready state (1.0 speed, Ready command). - public static LegacyRawMotionState Default() => new() - { - CurrentStyle = 0x8000003Du, - ForwardCommand = MotionCommand.Ready, - ForwardSpeed = 1.0f, - SideStepCommand = 0, - SideStepSpeed = 1.0f, - TurnCommand = 0, - TurnSpeed = 1.0f, - }; -} - -/// -/// Interpreted motion state, derived from the raw state. -/// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50). -/// public struct InterpretedMotionState { /// Forward/backward interpreted command (offset +0x4C). @@ -196,6 +236,20 @@ public struct InterpretedMotionState public uint TurnCommand; /// Speed scalar for turn (offset +0x60). public float TurnSpeed; + /// Current style / stance (retail current_style). Adopted from + /// the raw state's style channel; NOT part of retail's + /// InterpretedMotionState::ApplyMotion dispatch itself (that + /// writes this->current_style only via the negative-motion + /// branch — see ). Default NonCombat + /// 0x8000003D. + public uint CurrentStyle; + + private List? _actions; + + /// Action FIFO in retail order (oldest first). Empty (never + /// null) when read — the private field is lazily created. + public readonly IReadOnlyList Actions + => (IReadOnlyList?)_actions ?? Array.Empty(); /// Initialize to the idle/ready state. public static InterpretedMotionState Default() => new() @@ -206,7 +260,141 @@ public struct InterpretedMotionState SideStepSpeed = 1.0f, TurnCommand = 0, TurnSpeed = 1.0f, + CurrentStyle = 0x8000003Du, }; + + /// + /// InterpretedMotionState::AddAction (0x0051e9e0, decomp + /// 293500-293527): unconditional tail-append of + /// {motion, speed, action_stamp, autonomous}. Identical shape to + /// — retail duplicates the + /// LListData append logic per state type rather than sharing it. + /// + public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous) + { + _actions ??= new List(); + _actions.Add(new RawMotionAction( + Command: (ushort)motion, + Stamp: (ushort)actionStamp, + Autonomous: autonomous, + Speed: speed)); + } + + /// + /// InterpretedMotionState::RemoveAction (0x0051ead0, decomp + /// 293568-293586): pop the FIFO head unconditionally, returning its + /// motion field (0 when empty). + /// + public uint RemoveAction() + { + if (_actions is null || _actions.Count == 0) + return 0; + var head = _actions[0]; + _actions.RemoveAt(0); + return head.Command; + } + + /// + /// InterpretedMotionState::GetNumActions (0x0051eb00, decomp + /// 293590-293603): count the FIFO by walking it (retail has no O(1) + /// count field on the LList). + /// + public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0); + + /// + /// InterpretedMotionState::ApplyMotion (0x0051ea40, decomp + /// 293531-293564). Verbatim dispatch, quoted from the raw named decomp: + /// + /// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; } + /// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; } + /// if ((arg2 & 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; } + /// if (arg2 < 0) { forward_command = 0x41000003; current_style = arg2; return; } + /// if ((arg2 & 0x10000000) != 0) + /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1); + /// + /// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are + /// tested here — unlike , the + /// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased; + /// retail's adjust_motion normalizes Left→Right upstream before + /// this ever runs, so this asymmetry is verbatim, not a gap. + /// + /// Retail arg2 — the motion id AFTER + /// adjust_motion normalization (the ADJUSTED id, unlike + /// which takes the ORIGINAL). + /// Retail arg3 (MovementParameters const*). + public void ApplyMotion(uint motion, MovementParameters p) + { + if (motion == 0x6500000du) // TurnRight + { + TurnCommand = motion; + TurnSpeed = p.Speed; + return; + } + if (motion == 0x6500000fu) // SideStepRight + { + SideStepCommand = motion; + SideStepSpeed = p.Speed; + return; + } + if ((motion & 0x40000000u) != 0) + { + ForwardCommand = motion; + ForwardSpeed = p.Speed; + return; + } + if (motion >= 0x80000000u) // arg2 < 0 as signed int32 + { + ForwardCommand = 0x41000003u; + CurrentStyle = motion; + return; + } + if ((motion & 0x10000000u) != 0) + AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous); + } + + /// + /// InterpretedMotionState::RemoveMotion (0x0051e790, decomp + /// 293315-293340): + /// + /// if (arg2 == 0x6500000d) { turn_command = 0; return; } + /// if (arg2 == 0x6500000f) { sidestep_command = 0; return; } + /// if ((arg2 & 0x40000000) == 0) { + /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d; + /// } else if (arg2 == forward_command) { + /// forward_command = 0x41000003; forward_speed = 1f; + /// } + /// + /// Note the asymmetric range test vs + /// (which uses (arg2 - 0x6500000d) > 3 covering all four + /// turn/sidestep ids) — this one only special-cases the RIGHT variants + /// by exact equality; TurnLeft/SideStepLeft fall through to the + /// style/forward-command branch below. Verbatim, not a gap (mirrors + /// retail's own asymmetry between the two RemoveMotion bodies). + /// + /// Retail arg2. + public void RemoveMotion(uint motion) + { + if (motion == 0x6500000du) + { + TurnCommand = 0; + return; + } + if (motion == 0x6500000fu) + { + SideStepCommand = 0; + return; + } + if ((motion & 0x40000000u) == 0) + { + if (motion >= 0x80000000u && motion == CurrentStyle) + CurrentStyle = 0x8000003du; + } + else if (motion == ForwardCommand) + { + ForwardCommand = 0x41000003u; + ForwardSpeed = 1f; + } + } } /// @@ -317,8 +505,18 @@ public sealed class MotionInterpreter /// Optional WeenieObject for stamina / run-rate queries (struct offset +0x04). public IWeenieObject? WeenieObj { get; set; } - /// Raw (network-derived) motion state (struct offsets +0x14..+0x44). - public LegacyRawMotionState RawState; + /// + /// Raw (network-derived) motion state (struct offsets +0x14..+0x44). + /// + /// + /// R3-W1 fold (closes J2): this now holds the SAME retail-faithful, + /// full-field type the outbound + /// wire packer reads from — the former flat LegacyRawMotionState + /// struct (no action FIFO, no ApplyMotion/RemoveMotion) + /// is deleted. One raw-state type end to end. + /// + /// + public RawMotionState RawState; /// Interpreted motion state derived from raw (struct offsets +0x44..+0x7C). public InterpretedMotionState InterpretedState; @@ -385,7 +583,7 @@ public sealed class MotionInterpreter public MotionInterpreter() { - RawState = LegacyRawMotionState.Default(); + RawState = new RawMotionState(); InterpretedState = InterpretedMotionState.Default(); } @@ -393,7 +591,7 @@ public sealed class MotionInterpreter { PhysicsObj = physicsObj; WeenieObj = weenieObj; - RawState = LegacyRawMotionState.Default(); + RawState = new RawMotionState(); InterpretedState = InterpretedMotionState.Default(); } @@ -477,9 +675,12 @@ public sealed class MotionInterpreter if (!contact_allows_move(motion)) { - // Action commands (bit 0x10000000) are blocked mid-air. + // Action commands (bit 0x10000000) are blocked mid-air. A10: + // DoInterpretedMotion @305622-305623 returns 0x24 (NotGrounded) + // here, not 0x48 (that code is reserved for the + // motion_allows_jump literal-range blocklist). if ((motion & 0x10000000u) != 0) - return WeenieError.YouCantJumpWhileInTheAir; + return WeenieError.NotGrounded; // Non-action motions are queued silently; state still updates. } @@ -505,10 +706,10 @@ public sealed class MotionInterpreter RawState.ForwardCommand = MotionCommand.Ready; RawState.ForwardSpeed = 1.0f; } - if (RawState.SideStepCommand == motion) + if (RawState.SidestepCommand == motion) { - RawState.SideStepCommand = 0; - RawState.SideStepSpeed = 1.0f; + RawState.SidestepCommand = 0; + RawState.SidestepSpeed = 1.0f; } if (RawState.TurnCommand == motion) { @@ -563,7 +764,7 @@ public sealed class MotionInterpreter /// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump /// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready) /// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f) - /// *(+0x2c) = 0 (RawState.SideStepCommand = 0) + /// *(+0x2c) = 0 (RawState.SidestepCommand = 0) /// *(+0x38) = 0 (RawState.TurnCommand = 0) /// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready) /// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f) @@ -582,8 +783,8 @@ public sealed class MotionInterpreter // Reset raw state RawState.ForwardCommand = MotionCommand.Ready; RawState.ForwardSpeed = 1.0f; - RawState.SideStepCommand = 0; - RawState.SideStepSpeed = 1.0f; + RawState.SidestepCommand = 0; + RawState.SidestepSpeed = 1.0f; RawState.TurnCommand = 0; RawState.TurnSpeed = 1.0f; @@ -1039,7 +1240,7 @@ public sealed class MotionInterpreter /// elif WeenieObj.IsCreature() returns false: proceed /// iVar2 = PhysicsObj /// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity - /// return 0x24 (GeneralMovementFailure) + /// return 0x24 (NotGrounded) /// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47 /// if pending queue action has non-zero jump error: return that error /// iVar2 = FUN_00528660() (jump_charge_is_allowed) @@ -1052,8 +1253,13 @@ public sealed class MotionInterpreter /// public WeenieError jump_is_allowed(float extent, int staminaCost) { + // A10: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when + // physics_obj == null — the "8 = no physics obj" convention holds + // everywhere else in CMotionInterp but not here (raw structure + // 305512 + 305570: the `if (physics_obj != 0) {...}` wrapper falls + // out to `return 0x24;`). if (PhysicsObj is null) - return WeenieError.GeneralMovementFailure; + return WeenieError.NotGrounded; // Must have gravity and be grounded (Contact + OnWalkable) to start a jump. bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity); @@ -1061,7 +1267,7 @@ public sealed class MotionInterpreter && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); if (!hasGravity || !isGrounded) - return WeenieError.YouCantJumpWhileInTheAir; + return WeenieError.NotGrounded; // Delegate jump eligibility to WeenieObj if present. if (WeenieObj is not null && !WeenieObj.CanJump(extent)) @@ -1421,6 +1627,6 @@ public sealed class MotionInterpreter if ((motion & 0x10000000u) == 0) return WeenieError.None; // apply-only: state kept, no cycle change - return WeenieError.GeneralMovementFailure; // retail 0x24 + return WeenieError.NotGrounded; // retail 0x24 (A10) } } diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs index 33a1536a..315ee1ff 100644 --- a/src/AcDream.Core/Physics/RawMotionState.cs +++ b/src/AcDream.Core/Physics/RawMotionState.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Physics.Motion; + namespace AcDream.Core.Physics; /// @@ -15,57 +17,306 @@ public enum HoldKey : uint /// event (jump, attack, etc.) layered on top of the continuous /// forward/sidestep/turn axes. /// -/// Retail packs each action as u16 command then -/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)) -/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010). -/// -public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous); - -/// -/// Pure-data mirror of retail's RawMotionState struct -/// (acclient.h RawMotionState::PackBitfield, line 46474; -/// packer at RawMotionState::Pack, 0x0051ed10). +/// +/// Retail's in-memory action node (RawMotionState::AddAction, +/// 0x0051e840, and InterpretedMotionState::AddAction, 0x0051e9e0 — +/// both a 0x14-byte LListData-derived node) carries FOUR fields: +/// motion (+4), speed (+8), action_stamp (+0xc), +/// autonomous (+0x10). is the in-memory-only +/// field the R3-W1 action FIFO needs for ApplyMotion's velocity +/// bookkeeping; it is NOT part of the wire encoding. +/// /// /// -/// This type carries the COMPLETE motion-state snapshot the way retail's -/// CPhysicsObj::InqRawMotionState() would return it. The packer -/// () compares every field against -/// and only emits the fields that differ — see -/// RawMotionState::Pack for the exact default-difference logic. +/// Retail packs each action ON THE WIRE as u16 command then +/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)) +/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010) +/// — // are +/// exactly the packed triple; reads only +/// those three. +/// +/// +public readonly record struct RawMotionAction( + ushort Command, + ushort Stamp, + bool Autonomous, + float Speed = 1f); + +/// +/// R3-W1 — verbatim, full-field port of retail's RawMotionState +/// (acclient.h RawMotionState::PackBitfield, line 46474; ctor +/// RawMotionState::RawMotionState, 0x0051e7f0, decomp lines +/// 293344-293361; packer at RawMotionState::Pack, 0x0051ed10). +/// +/// +/// R3-W1 fold (closes J2): this type now ALSO serves as +/// MotionInterpreter.RawState — the former LegacyRawMotionState +/// (a flat 7-field struct with no action FIFO) is deleted. One raw-state +/// type end to end: the physics-side motion interpreter mutates it via +/// // +/// /, and the SAME +/// instance's is read by +/// when building the outbound wire packet. Previously the packer's action +/// list was always empty (register TS-24) because nothing populated it — +/// starting R3-W2 (the add_to_queue/MotionDone port), +/// CMotionInterp populates this list locally exactly like retail +/// does, so the packed bytes will start reflecting real queued actions. +/// W1 itself does not change what gets packed (no caller invokes +/// yet) — only adds the capability + tests. /// /// /// /// PURE DATA: no PacketWriter / GL / Net dependency. Lives in -/// AcDream.Core.Physics so the (future) motion interpreter, which is +/// AcDream.Core.Physics so the motion interpreter, which is /// also Core.Physics, can populate it without taking a Core.Net dependency /// (Code Structure Rule #2). /// /// public sealed class RawMotionState { - public HoldKey CurrentHoldKey { get; init; } = HoldKey.None; - public uint CurrentStyle { get; init; } = 0x8000003Du; - public uint ForwardCommand { get; init; } = 0x41000003u; - public HoldKey ForwardHoldKey { get; init; } = HoldKey.Invalid; - public float ForwardSpeed { get; init; } = 1.0f; - public uint SidestepCommand { get; init; } = 0u; - public HoldKey SidestepHoldKey { get; init; } = HoldKey.Invalid; - public float SidestepSpeed { get; init; } = 1.0f; - public uint TurnCommand { get; init; } = 0u; - public HoldKey TurnHoldKey { get; init; } = HoldKey.Invalid; - public float TurnSpeed { get; init; } = 1.0f; + /// Retail current_holdkey (ctor default HoldKey_None). + public HoldKey CurrentHoldKey { get; set; } = HoldKey.None; + /// Retail current_style (ctor default 0x8000003D, NonCombat). + public uint CurrentStyle { get; set; } = 0x8000003Du; + /// Retail forward_command (ctor default 0x41000003, Ready). + public uint ForwardCommand { get; set; } = 0x41000003u; + /// Retail forward_holdkey (ctor default HoldKey_Invalid). + public HoldKey ForwardHoldKey { get; set; } = HoldKey.Invalid; + /// Retail forward_speed (ctor default 1.0). + public float ForwardSpeed { get; set; } = 1.0f; + /// Retail sidestep_command (ctor default 0). + public uint SidestepCommand { get; set; } + /// Retail sidestep_holdkey (ctor default HoldKey_Invalid). + public HoldKey SidestepHoldKey { get; set; } = HoldKey.Invalid; + /// Retail sidestep_speed (ctor default 1.0). + public float SidestepSpeed { get; set; } = 1.0f; + /// Retail turn_command (ctor default 0). + public uint TurnCommand { get; set; } + /// Retail turn_holdkey (ctor default HoldKey_Invalid). + public HoldKey TurnHoldKey { get; set; } = HoldKey.Invalid; + /// Retail turn_speed (ctor default 1.0). + public float TurnSpeed { get; set; } = 1.0f; + + private readonly List _actions = new(); /// - /// Discrete action list. Retail packs num_actions (count, not the + /// Discrete action FIFO (retail actions, an LListData + /// tail-append queue). Retail packs num_actions (count, not the /// values) into bits 11-15 of the flags dword, then emits each action's - /// command+stamp pair unconditionally after the continuous-axis fields. + /// command+stamp pair unconditionally after the continuous-axis fields + /// (). + /// + /// + /// Settable via object-initializer for test fixtures / the wire-packer + /// call sites that build a one-shot snapshot; assigning replaces the + /// whole FIFO. / are + /// the retail-faithful mutators for live FIFO discipline. + /// /// - public IReadOnlyList Actions { get; init; } = Array.Empty(); + public IReadOnlyList Actions + { + get => _actions; + set + { + _actions.Clear(); + _actions.AddRange(value); + } + } /// /// Retail defaults — a field bit is set in the packed flags dword ONLY /// when the live value differs from these (RawMotionState::Pack, - /// 0x0051ed10). + /// 0x0051ed10). NEVER mutate this shared instance. /// public static readonly RawMotionState Default = new(); + + /// + /// RawMotionState::AddAction (0x0051e840, decomp 293365-293392): + /// unconditional tail-append of + /// {motion, speed, action_stamp, autonomous}. + /// + /// Retail arg2 — the action motion id. + /// Retail arg3. + /// Retail arg4. + /// Retail arg5 (nonzero = autonomous). + public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous) + { + _actions.Add(new RawMotionAction( + Command: (ushort)motion, + Stamp: (ushort)actionStamp, + Autonomous: autonomous, + Speed: speed)); + } + + /// + /// RawMotionState::RemoveAction (0x0051e8a0, decomp 293396-293414): + /// pop the FIFO head unconditionally, returning its motion field + /// (0 when empty — retail returns *(head+4), i.e. the stored + /// arg2/Command, widened; the C# port returns the widened + /// as a uint). + /// + public uint RemoveAction() + { + if (_actions.Count == 0) + return 0; + var head = _actions[0]; + _actions.RemoveAt(0); + return head.Command; + } + + /// + /// RawMotionState::ApplyMotion (0x0051eb60, decomp 293630-293703). + /// Verbatim dispatch, quoted from the raw named decomp: + /// + /// if ((arg2 - 0x6500000d) > 3) { // outside [0x6500000d, 0x65000010] + /// if ((arg2 & 0x40000000) == 0) { // NOT forward-class + /// if (arg2 >= 0) { // NOT style-class (high bit clear) + /// if ((arg2 & 0x10000000) != 0) + /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1); + /// } else if (current_style != arg2) { + /// forward_command = 0x41000003; current_style = arg2; + /// } + /// } else if (arg2 != 0x44000007) { // forward-class, NOT RunForward + /// forward_command = arg2; + /// if (params.bitfield byte1 & 8 != 0) { forward_holdkey = Invalid; forward_speed = params.speed; } + /// else { forward_holdkey = params.hold_key_to_apply; forward_speed = params.speed; } + /// } + /// // arg2 == 0x44000007 (RunForward) with the forward-class bit set: falls through, no write. + /// return; + /// } + /// switch (arg2) { + /// case 0x6500000d: case 0x6500000e: // TurnRight/TurnLeft + /// turn_command = arg2; + /// if (SetHoldKey bit) { turn_holdkey = Invalid; turn_speed = params.speed; } + /// else { turn_holdkey = params.hold_key_to_apply; turn_speed = params.speed; } + /// return; + /// case 0x6500000f: case 0x65000010: // SideStepRight/SideStepLeft + /// sidestep_command = arg2; + /// if (SetHoldKey bit) { sidestep_holdkey = Invalid; sidestep_speed = params.speed; } + /// else { sidestep_holdkey = params.hold_key_to_apply; sidestep_speed = params.speed; } + /// return; + /// } + /// + /// The arg2 == 0x44000007 (RunForward) no-write fallthrough on the + /// forward-class branch is a genuine retail quirk (RunForward bit + /// 0x40000000 IS set, but the `else if (arg2 != 0x44000007)` guard + /// excludes it) — ported verbatim, not "fixed". + /// + /// Retail arg2 — the ORIGINAL (pre-adjustment) + /// motion id per the DoMotion/StopMotion mirror-discipline callers. + /// Retail arg3 (MovementParameters const*). + public void ApplyMotion(uint motion, MovementParameters p) + { + if (motion - 0x6500000du > 3u) + { + if ((motion & 0x40000000u) == 0) + { + if (motion < 0x80000000u) // arg2 >= 0 as signed int32 + { + if ((motion & 0x10000000u) != 0) + AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous); + } + else if (CurrentStyle != motion) + { + ForwardCommand = 0x41000003u; + CurrentStyle = motion; + } + } + else if (motion != 0x44000007u) + { + ForwardCommand = motion; + if (p.SetHoldKey) + { + ForwardHoldKey = HoldKey.Invalid; + ForwardSpeed = p.Speed; + } + else + { + ForwardHoldKey = p.HoldKeyToApply; + ForwardSpeed = p.Speed; + } + } + return; + } + + switch (motion) + { + case 0x6500000du: // TurnRight + case 0x6500000eu: // TurnLeft + TurnCommand = motion; + if (p.SetHoldKey) + { + TurnHoldKey = HoldKey.Invalid; + TurnSpeed = p.Speed; + } + else + { + TurnHoldKey = p.HoldKeyToApply; + TurnSpeed = p.Speed; + } + return; + case 0x6500000fu: // SideStepRight + case 0x65000010u: // SideStepLeft + SidestepCommand = motion; + if (p.SetHoldKey) + { + SidestepHoldKey = HoldKey.Invalid; + SidestepSpeed = p.Speed; + } + else + { + SidestepHoldKey = p.HoldKeyToApply; + SidestepSpeed = p.Speed; + } + return; + } + } + + /// + /// RawMotionState::RemoveMotion (0x0051e6e0, decomp 293252-293288): + /// + /// if ((arg2 - 0x6500000d) > 3) { + /// if ((arg2 & 0x40000000) == 0) { + /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d; + /// } else if (arg2 == forward_command) { + /// forward_command = 0x41000003; forward_speed = 1f; + /// } + /// return; + /// } + /// switch (arg2) { + /// case 0x6500000d: case 0x6500000e: turn_command = 0; return; + /// case 0x6500000f: case 0x65000010: sidestep_command = 0; return; + /// } + /// + /// + /// Retail arg2 — the ORIGINAL motion id. + public void RemoveMotion(uint motion) + { + if (motion - 0x6500000du > 3u) + { + if ((motion & 0x40000000u) == 0) + { + if (motion >= 0x80000000u && motion == CurrentStyle) + CurrentStyle = 0x8000003du; + } + else if (motion == ForwardCommand) + { + ForwardCommand = 0x41000003u; + ForwardSpeed = 1f; + } + return; + } + + switch (motion) + { + case 0x6500000du: + case 0x6500000eu: + TurnCommand = 0; + return; + case 0x6500000fu: + case 0x65000010u: + SidestepCommand = 0; + return; + } + } } diff --git a/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs new file mode 100644 index 00000000..68420929 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs @@ -0,0 +1,217 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion +/// field effects on (closes J2). Oracle: +/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies +/// quoted in MotionInterpreter.cs doc comments: +/// InterpretedMotionState::AddAction (0x0051e9e0), RemoveAction +/// (0x0051ead0), GetNumActions (0x0051eb00), ApplyMotion +/// (0x0051ea40), RemoveMotion (0x0051e790). +/// +public sealed class InterpretedMotionStateActionFifoTests +{ + [Fact] + public void Default_HasEmptyActionsAndZeroCount() + { + var ims = InterpretedMotionState.Default(); + Assert.Empty(ims.Actions); + Assert.Equal(0u, ims.GetNumActions()); + } + + // ── AddAction / RemoveAction / GetNumActions FIFO discipline ────────── + + [Fact] + public void AddAction_AppendsInOrder_GetNumActionsCounts() + { + var ims = InterpretedMotionState.Default(); + ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); + ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true); + + Assert.Equal(2u, ims.GetNumActions()); + Assert.Equal((ushort)0x004Bu, ims.Actions[0].Command); + Assert.Equal((ushort)0x0050u, ims.Actions[1].Command); + } + + [Fact] + public void RemoveAction_PopsHeadFirst_FifoOrder() + { + var ims = InterpretedMotionState.Default(); + ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); + ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true); + + uint first = ims.RemoveAction(); + uint second = ims.RemoveAction(); + + Assert.Equal(0x004Bu, first); + Assert.Equal(0x0050u, second); + Assert.Equal(0u, ims.GetNumActions()); + } + + [Fact] + public void RemoveAction_Empty_ReturnsZero() + { + var ims = InterpretedMotionState.Default(); + Assert.Equal(0u, ims.RemoveAction()); + } + + [Fact] + public void GetNumActions_DefaultStruct_NoNullRef() + { + // Defensive: a bare default(InterpretedMotionState) (bypassing + // .Default()) must not NRE on GetNumActions/Actions/RemoveAction — + // the lazy-list field starts null. + InterpretedMotionState bare = default; + Assert.Equal(0u, bare.GetNumActions()); + Assert.Empty(bare.Actions); + Assert.Equal(0u, bare.RemoveAction()); + } + + // ── DoMotion's depth-cap gate consumes this directly (documentation) ── + + [Fact] + public void GetNumActions_SixQueued_MeetsDoMotionDepthCapThreshold() + { + // DoMotion (0x00528d20 @306159) rejects with 0x45 when an + // action-class motion arrives AND GetNumActions() >= 6. W1 only + // proves the counter is correct; the gate itself lands in W5. + var ims = InterpretedMotionState.Default(); + for (uint i = 0; i < 6; i++) + ims.AddAction(0x10000000u + i, 1.0f, i, false); + + Assert.Equal(6u, ims.GetNumActions()); + Assert.True(ims.GetNumActions() >= 6); + } + + // ── ApplyMotion field effects (0x0051ea40) ───────────────────────────── + + [Fact] + public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed() + { + var ims = InterpretedMotionState.Default(); + var p = new MovementParameters { Speed = 1.5f }; + + ims.ApplyMotion(0x6500000du, p); + + Assert.Equal(0x6500000du, ims.TurnCommand); + Assert.Equal(1.5f, ims.TurnSpeed); + } + + [Fact] + public void ApplyMotion_SideStepRight_SetsSideStepCommandAndSpeed() + { + var ims = InterpretedMotionState.Default(); + var p = new MovementParameters { Speed = 1.248f }; + + ims.ApplyMotion(0x6500000fu, p); + + Assert.Equal(0x6500000fu, ims.SideStepCommand); + Assert.Equal(1.248f, ims.SideStepSpeed); + } + + [Fact] + public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed() + { + // Unlike RawMotionState::ApplyMotion, InterpretedMotionState's + // forward-class branch has NO RunForward exclusion — every + // forward-class id (bit 0x40000000) writes forward_command. + var ims = InterpretedMotionState.Default(); + var p = new MovementParameters { Speed = 2.94f }; + + ims.ApplyMotion(0x44000007u, p); // RunForward + + Assert.Equal(0x44000007u, ims.ForwardCommand); + Assert.Equal(2.94f, ims.ForwardSpeed); + } + + [Fact] + public void ApplyMotion_StyleClassMotion_ResetsForwardToReady_SetsCurrentStyle() + { + var ims = InterpretedMotionState.Default(); + ims.ForwardCommand = 0x45000005u; + var p = new MovementParameters(); + + ims.ApplyMotion(0x80000042u, p); + + Assert.Equal(0x41000003u, ims.ForwardCommand); + Assert.Equal(0x80000042u, ims.CurrentStyle); + } + + [Fact] + public void ApplyMotion_ActionClassMotion_AddsAction() + { + var ims = InterpretedMotionState.Default(); + var p = new MovementParameters { Speed = 1.0f, ActionStamp = 7u, Autonomous = true }; + + ims.ApplyMotion(0x1000004Bu, p); + + Assert.Equal(1u, ims.GetNumActions()); + var a = ims.Actions[0]; + Assert.Equal((ushort)0x004Bu, a.Command); + Assert.Equal(1.0f, a.Speed); + Assert.Equal((ushort)7u, a.Stamp); + Assert.True(a.Autonomous); + } + + // ── RemoveMotion field effects (0x0051e790) ──────────────────────────── + + [Fact] + public void RemoveMotion_TurnRightExact_ClearsTurnCommand() + { + var ims = InterpretedMotionState.Default(); + ims.TurnCommand = 0x6500000du; + ims.RemoveMotion(0x6500000du); + Assert.Equal(0u, ims.TurnCommand); + } + + [Fact] + public void RemoveMotion_TurnLeftExact_DoesNotMatchTurnBranch_FallsThroughToStyleCheck() + { + // Asymmetric vs RawMotionState::RemoveMotion: InterpretedMotionState + // only exact-matches 0x6500000d (TurnRight) for the turn branch, NOT + // 0x6500000e (TurnLeft) — verbatim per the raw decomp. + var ims = InterpretedMotionState.Default(); + ims.TurnCommand = 0x6500000eu; + ims.RemoveMotion(0x6500000eu); + // Falls through: bit 0x40000000 clear, arg2 (0x6500000e) is not + // negative and != current_style (0x8000003D default) -> no-op. + Assert.Equal(0x6500000eu, ims.TurnCommand); // untouched + } + + [Fact] + public void RemoveMotion_SideStepRightExact_ClearsSideStepCommand() + { + var ims = InterpretedMotionState.Default(); + ims.SideStepCommand = 0x6500000fu; + ims.RemoveMotion(0x6500000fu); + Assert.Equal(0u, ims.SideStepCommand); + } + + [Fact] + public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady() + { + var ims = InterpretedMotionState.Default(); + ims.ForwardCommand = 0x45000005u; + ims.ForwardSpeed = 3.0f; + + ims.RemoveMotion(0x45000005u); + + Assert.Equal(0x41000003u, ims.ForwardCommand); + Assert.Equal(1f, ims.ForwardSpeed); + } + + [Fact] + public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat() + { + var ims = InterpretedMotionState.Default(); + ims.CurrentStyle = 0x80000042u; + + ims.RemoveMotion(0x80000042u); + + Assert.Equal(0x8000003du, ims.CurrentStyle); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs new file mode 100644 index 00000000..78fa2bcf --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs @@ -0,0 +1,33 @@ +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R3-W1 — CMotionInterp::MotionNode shape pin. Oracle: +/// acclient.h:53293 (struct #5857). W2 consumes this as the +/// pending_motions element type; W1 only ports the shape. +/// +public sealed class MotionNodeTests +{ + [Fact] + public void Ctor_StoresAllThreeFields() + { + var node = new MotionNode(ContextId: 7u, Motion: 0x41000003u, JumpErrorCode: 0x48u); + + Assert.Equal(7u, node.ContextId); + Assert.Equal(0x41000003u, node.Motion); + Assert.Equal(0x48u, node.JumpErrorCode); + } + + [Fact] + public void Equality_IsValueBased() + { + var a = new MotionNode(1u, 2u, 3u); + var b = new MotionNode(1u, 2u, 3u); + var c = new MotionNode(1u, 2u, 4u); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs new file mode 100644 index 00000000..6171f2d2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs @@ -0,0 +1,112 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R3-W1 — MovementParameters ctor-default pins. Oracle: +/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bitfield 0x1EE0F +/// expansion) + r3-motioninterp-decomp.md §0 (scalar ctor, raw 300510-300534, +/// 0x00524380). Every flag asserted individually against the retail +/// 0x1EE0F default so a future bit-numbering slip fails loudly, plus the two +/// ACE-divergence traps (CanCharge, WalkRunThreshhold) get dedicated tests. +/// +public sealed class MovementParametersTests +{ + [Fact] + public void Ctor_SetFlags_MatchRetail0x1EE0FExpansion() + { + var p = new MovementParameters(); + + // Bits SET in 0x1EE0F: 0x1,0x2,0x4,0x8,0x200,0x400,0x800,0x2000,0x4000,0x8000,0x10000 + Assert.True(p.CanWalk); // 0x1 + Assert.True(p.CanRun); // 0x2 + Assert.True(p.CanSidestep); // 0x4 + Assert.True(p.CanWalkBackwards); // 0x8 + Assert.True(p.MoveTowards); // 0x200 + Assert.True(p.UseSpheres); // 0x400 + Assert.True(p.SetHoldKey); // 0x800 + Assert.True(p.ModifyRawState); // 0x2000 + Assert.True(p.ModifyInterpretedState); // 0x4000 + Assert.True(p.CancelMoveTo); // 0x8000 + Assert.True(p.StopCompletelyFlag); // 0x10000 + } + + [Fact] + public void Ctor_ClearFlags_MatchRetail0x1EE0FExpansion() + { + var p = new MovementParameters(); + + // Bits CLEAR in 0x1EE0F: 0x10,0x20,0x40,0x80,0x100,0x1000,0x20000 + Assert.False(p.CanCharge); // 0x10 — ACE-divergence trap + Assert.False(p.FailWalk); // 0x20 + Assert.False(p.UseFinalHeading); // 0x40 + Assert.False(p.Sticky); // 0x80 + Assert.False(p.MoveAway); // 0x100 + Assert.False(p.Autonomous); // 0x1000 + Assert.False(p.DisableJumpDuringLink); // 0x20000 + } + + [Fact] + public void Ctor_Bitfield_ReconstitutesExactly0x1EE0F() + { + var p = new MovementParameters(); + + uint bitfield = 0; + if (p.CanWalk) bitfield |= 0x1; + if (p.CanRun) bitfield |= 0x2; + if (p.CanSidestep) bitfield |= 0x4; + if (p.CanWalkBackwards) bitfield |= 0x8; + if (p.CanCharge) bitfield |= 0x10; + if (p.FailWalk) bitfield |= 0x20; + if (p.UseFinalHeading) bitfield |= 0x40; + if (p.Sticky) bitfield |= 0x80; + if (p.MoveAway) bitfield |= 0x100; + if (p.MoveTowards) bitfield |= 0x200; + if (p.UseSpheres) bitfield |= 0x400; + if (p.SetHoldKey) bitfield |= 0x800; + if (p.Autonomous) bitfield |= 0x1000; + if (p.ModifyRawState) bitfield |= 0x2000; + if (p.ModifyInterpretedState) bitfield |= 0x4000; + if (p.CancelMoveTo) bitfield |= 0x8000; + if (p.StopCompletelyFlag) bitfield |= 0x10000; + if (p.DisableJumpDuringLink) bitfield |= 0x20000; + + Assert.Equal(0x1EE0Fu, bitfield); + } + + [Fact] + public void Ctor_ScalarDefaults_MatchRetail() + { + var p = new MovementParameters(); + + Assert.Equal(0f, p.MinDistance); + Assert.Equal(0.6f, p.DistanceToObject); + Assert.Equal(float.MaxValue, p.FailDistance); + Assert.Equal(0f, p.DesiredHeading); + Assert.Equal(1f, p.Speed); + // ACE-divergence trap: retail is 15.0, NOT ACE's 1.0. + Assert.Equal(15f, p.WalkRunThreshhold); + Assert.Equal(0u, p.ContextId); + Assert.Equal(HoldKey.Invalid, p.HoldKeyToApply); + Assert.Equal(0u, p.ActionStamp); + } + + [Fact] + public void Ctor_CanCharge_DefaultsFalse_NotAceTrue() + { + // Dedicated regression test for the single highest-risk ACE trap: + // ACE's MovementParameters.cs:58 sets CanCharge = true at construction. + // Retail's 0x1EE0F has bit 0x10 CLEAR. + var p = new MovementParameters(); + Assert.False(p.CanCharge); + } + + [Fact] + public void Ctor_WalkRunThreshhold_Is15_NotAce1() + { + var p = new MovementParameters(); + Assert.Equal(15f, p.WalkRunThreshhold); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs new file mode 100644 index 00000000..cd27ee7c --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs @@ -0,0 +1,244 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion +/// field effects on (closes J2). Oracle: +/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies +/// quoted in RawMotionState.cs doc comments: +/// RawMotionState::AddAction (0x0051e840), RemoveAction +/// (0x0051e8a0), ApplyMotion (0x0051eb60), RemoveMotion +/// (0x0051e6e0). +/// +public sealed class RawMotionStateActionFifoTests +{ + // ── AddAction / RemoveAction / GetNumActions FIFO discipline ────────── + + [Fact] + public void AddAction_AppendsInOrder() + { + var raw = new RawMotionState(); + raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); + raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true); + + Assert.Equal(2, raw.Actions.Count); + Assert.Equal((ushort)0x004Bu, raw.Actions[0].Command); // widened to ushort on wire, verified below + Assert.Equal((ushort)0x0050u, raw.Actions[1].Command); + } + + [Fact] + public void RemoveAction_PopsHeadFirst_FifoOrder() + { + var raw = new RawMotionState(); + raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); + raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true); + + uint first = raw.RemoveAction(); + uint second = raw.RemoveAction(); + + Assert.Equal(0x004Bu, first); // head popped first (FIFO) + Assert.Equal(0x0050u, second); + Assert.Empty(raw.Actions); + } + + [Fact] + public void RemoveAction_Empty_ReturnsZero() + { + var raw = new RawMotionState(); + Assert.Equal(0u, raw.RemoveAction()); + } + + [Fact] + public void AddAction_StoresSpeedStampAutonomous() + { + var raw = new RawMotionState(); + raw.AddAction(0x1000004Bu, speed: 2.5f, actionStamp: 0x7FFFu, autonomous: true); + + var a = raw.Actions[0]; + Assert.Equal(2.5f, a.Speed); + Assert.Equal((ushort)0x7FFFu, a.Stamp); + Assert.True(a.Autonomous); + } + + // ── ApplyMotion field effects (0x0051eb60) ───────────────────────────── + + [Fact] + public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed_HonorsSetHoldKeyBit() + { + var raw = new RawMotionState(); + var p = new MovementParameters { Speed = 1.5f, SetHoldKey = true }; + + raw.ApplyMotion(0x6500000du, p); // TurnRight + + Assert.Equal(0x6500000du, raw.TurnCommand); + Assert.Equal(1.5f, raw.TurnSpeed); + Assert.Equal(HoldKey.Invalid, raw.TurnHoldKey); // SetHoldKey bit set -> Invalid + } + + [Fact] + public void ApplyMotion_TurnRight_SetHoldKeyClear_UsesHoldKeyToApply() + { + var raw = new RawMotionState(); + var p = new MovementParameters { Speed = 1.5f, SetHoldKey = false, HoldKeyToApply = HoldKey.Run }; + + raw.ApplyMotion(0x6500000du, p); + + Assert.Equal(HoldKey.Run, raw.TurnHoldKey); + } + + [Fact] + public void ApplyMotion_SideStepRight_SetsSidestepCommandAndSpeed() + { + var raw = new RawMotionState(); + var p = new MovementParameters { Speed = 1.248f, SetHoldKey = true }; + + raw.ApplyMotion(0x6500000fu, p); // SideStepRight + + Assert.Equal(0x6500000fu, raw.SidestepCommand); + Assert.Equal(1.248f, raw.SidestepSpeed); + Assert.Equal(HoldKey.Invalid, raw.SidestepHoldKey); + } + + [Fact] + public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed() + { + // WalkForward = 0x45000005 has bit 0x40000000 set (forward-class) + // and is NOT 0x44000007 (RunForward) -> the write branch fires. + var raw = new RawMotionState(); + var p = new MovementParameters { Speed = 1.0f, SetHoldKey = true }; + + raw.ApplyMotion(0x45000005u, p); + + Assert.Equal(0x45000005u, raw.ForwardCommand); + Assert.Equal(1.0f, raw.ForwardSpeed); + Assert.Equal(HoldKey.Invalid, raw.ForwardHoldKey); + } + + [Fact] + public void ApplyMotion_RunForwardExactId_ForwardClassButExcluded_NoWrite() + { + // Verbatim retail quirk (0x0051eb60): arg2 == 0x44000007 (RunForward) + // with the forward-class bit (0x40000000) set falls through BOTH + // inner branches — no field write occurs. Port verbatim, not fixed. + var raw = new RawMotionState(); + var before = raw.ForwardCommand; + var p = new MovementParameters { Speed = 3.0f }; + + raw.ApplyMotion(0x44000007u, p); + + Assert.Equal(before, raw.ForwardCommand); // untouched + Assert.Equal(1.0f, raw.ForwardSpeed); // untouched (still ctor default) + } + + [Fact] + public void ApplyMotion_StyleClassMotion_SetsCurrentStyleAndResetsForwardToReady() + { + // High bit set (>= 0x80000000) and current_style differs -> style branch. + var raw = new RawMotionState { ForwardCommand = 0x45000005u }; + var p = new MovementParameters(); + + raw.ApplyMotion(0x80000042u, p); + + Assert.Equal(0x41000003u, raw.ForwardCommand); // reset to Ready + Assert.Equal(0x80000042u, raw.CurrentStyle); + } + + [Fact] + public void ApplyMotion_StyleClassMotion_SameAsCurrentStyle_NoOp() + { + var raw = new RawMotionState { CurrentStyle = 0x80000042u, ForwardCommand = 0x45000005u }; + var p = new MovementParameters(); + + raw.ApplyMotion(0x80000042u, p); + + // current_style already equals arg2 -> the style branch's condition + // (current_style != arg2) is false, so forward_command is untouched. + Assert.Equal(0x45000005u, raw.ForwardCommand); + Assert.Equal(0x80000042u, raw.CurrentStyle); + } + + [Fact] + public void ApplyMotion_ActionClassMotion_AddsAction() + { + // Outside turn/sidestep range, bit 0x40000000 clear, arg2 >= 0 + // (not style-class), bit 0x10000000 set -> AddAction. + var raw = new RawMotionState(); + var p = new MovementParameters { Speed = 1.0f, ActionStamp = 42u, Autonomous = true }; + + raw.ApplyMotion(0x1000004Bu, p); // Jumpup action id + + Assert.Single(raw.Actions); + var a = raw.Actions[0]; + Assert.Equal((ushort)0x004Bu, a.Command); + Assert.Equal(1.0f, a.Speed); + Assert.Equal((ushort)42u, a.Stamp); + Assert.True(a.Autonomous); + } + + // ── RemoveMotion field effects (0x0051e6e0) ──────────────────────────── + + [Fact] + public void RemoveMotion_TurnRange_ClearsTurnCommand() + { + var raw = new RawMotionState { TurnCommand = 0x6500000du }; + raw.RemoveMotion(0x6500000du); + Assert.Equal(0u, raw.TurnCommand); + } + + [Fact] + public void RemoveMotion_TurnLeftRange_ClearsTurnCommand() + { + var raw = new RawMotionState { TurnCommand = 0x6500000eu }; + raw.RemoveMotion(0x6500000eu); + Assert.Equal(0u, raw.TurnCommand); + } + + [Fact] + public void RemoveMotion_SidestepRange_ClearsSidestepCommand() + { + var raw = new RawMotionState { SidestepCommand = 0x6500000fu }; + raw.RemoveMotion(0x6500000fu); + Assert.Equal(0u, raw.SidestepCommand); + } + + [Fact] + public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady() + { + var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f }; + raw.RemoveMotion(0x45000005u); + + Assert.Equal(0x41000003u, raw.ForwardCommand); + Assert.Equal(1f, raw.ForwardSpeed); + } + + [Fact] + public void RemoveMotion_ForwardClassMotion_NonMatchingCommand_NoOp() + { + var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f }; + raw.RemoveMotion(0x44000007u); // different forward-class id + + Assert.Equal(0x45000005u, raw.ForwardCommand); // untouched + Assert.Equal(3.0f, raw.ForwardSpeed); + } + + [Fact] + public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat() + { + var raw = new RawMotionState { CurrentStyle = 0x80000042u }; + raw.RemoveMotion(0x80000042u); + + Assert.Equal(0x8000003du, raw.CurrentStyle); // reset to NonCombat + } + + [Fact] + public void RemoveMotion_StyleClassMotion_NonMatchingCurrentStyle_NoOp() + { + var raw = new RawMotionState { CurrentStyle = 0x80000042u }; + raw.RemoveMotion(0x80000099u); // different style id + + Assert.Equal(0x80000042u, raw.CurrentStyle); // untouched + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index 7c3cb769..2ed8c38b 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -176,7 +176,7 @@ public sealed class MotionInterpreterTests { var interp = MakeInterp(); interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.SideStepCommand = MotionCommand.SideStepRight; + interp.RawState.SidestepCommand = MotionCommand.SideStepRight; interp.RawState.TurnCommand = MotionCommand.TurnRight; interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; interp.InterpretedState.TurnCommand = MotionCommand.TurnLeft; @@ -186,7 +186,7 @@ public sealed class MotionInterpreterTests Assert.Equal(WeenieError.None, result); Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); Assert.Equal(1.0f, interp.RawState.ForwardSpeed, precision: 5); - Assert.Equal(0u, interp.RawState.SideStepCommand); + Assert.Equal(0u, interp.RawState.SidestepCommand); Assert.Equal(0u, interp.RawState.TurnCommand); Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed, precision: 5); @@ -440,14 +440,16 @@ public sealed class MotionInterpreterTests } [Fact] - public void Jump_Airborne_ReturnsYouCantJumpWhileInTheAir() + public void Jump_Airborne_ReturnsNotGrounded() { + // R3-W1 renumber (A10): airborne is 0x24 (NotGrounded), not 0x48 + // (0x48 is now reserved for the motion_allows_jump blocklist). var body = MakeAirborne(); var interp = MakeInterp(body); var result = interp.jump(0.5f); - Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + Assert.Equal(WeenieError.NotGrounded, result); } [Fact] @@ -586,13 +588,13 @@ public sealed class MotionInterpreterTests } [Fact] - public void JumpIsAllowed_Airborne_ReturnsYouCantJumpWhileInTheAir() + public void JumpIsAllowed_Airborne_ReturnsNotGrounded() { var interp = MakeInterp(MakeAirborne()); var result = interp.jump_is_allowed(0.5f, 0); - Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + Assert.Equal(WeenieError.NotGrounded, result); } [Fact] @@ -607,7 +609,7 @@ public sealed class MotionInterpreterTests } [Fact] - public void JumpIsAllowed_NoGravityFlag_ReturnsYouCantJumpWhileInTheAir() + public void JumpIsAllowed_NoGravityFlag_ReturnsNotGrounded() { var body = new PhysicsBody { @@ -619,17 +621,20 @@ public sealed class MotionInterpreterTests // No gravity → must be airborne-style var result = interp.jump_is_allowed(0.5f, 0); - Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + Assert.Equal(WeenieError.NotGrounded, result); } [Fact] - public void JumpIsAllowed_NullPhysicsObj_ReturnsGeneralFailure() + public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded() { + // A10 note: jump_is_allowed returns 0x24 (NotGrounded), NOT 8, when + // physics_obj == null — the "8 = no physics obj" convention that + // holds everywhere else in CMotionInterp does not hold here. var interp = new MotionInterpreter(); var result = interp.jump_is_allowed(0.5f, 0); - Assert.Equal(WeenieError.GeneralMovementFailure, result); + Assert.Equal(WeenieError.NotGrounded, result); } // ========================================================================= diff --git a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs new file mode 100644 index 00000000..383e8b34 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs @@ -0,0 +1,81 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// R3-W1 — pins 's numeric values against the +/// definitive retail table in +/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (an exhaustive +/// sweep of every return <code> site across raw 304908-306277 + +/// 300150-300540: 19 return sites + 1 store site, all attributed). These +/// codes are local-only (never on the wire), so the renumber from the +/// pre-R3 shuffled names is a safe rename — this test is the single +/// source of truth that a future edit can't silently un-pin. +/// +public sealed class WeenieErrorCodeTableTests +{ + [Fact] + public void None_Is0x00() + => Assert.Equal(0x00u, (uint)WeenieError.None); + + [Fact] + public void NoPhysicsObject_Is0x08() + => Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject); + + [Fact] + public void NotGrounded_Is0x24() + => Assert.Equal(0x24u, (uint)WeenieError.NotGrounded); + + [Fact] + public void CrouchInCombatStance_Is0x3f() + => Assert.Equal(0x3fu, (uint)WeenieError.CrouchInCombatStance); + + [Fact] + public void SitInCombatStance_Is0x40() + => Assert.Equal(0x40u, (uint)WeenieError.SitInCombatStance); + + [Fact] + public void SleepInCombatStance_Is0x41() + => Assert.Equal(0x41u, (uint)WeenieError.SleepInCombatStance); + + [Fact] + public void ChatEmoteOutsideNonCombat_Is0x42() + => Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat); + + [Fact] + public void ActionDepthExceeded_Is0x45() + => Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded); + + [Fact] + public void GeneralMovementFailure_Is0x47() + => Assert.Equal(0x47u, (uint)WeenieError.GeneralMovementFailure); + + [Fact] + public void YouCantJumpFromThisPosition_Is0x48() + => Assert.Equal(0x48u, (uint)WeenieError.YouCantJumpFromThisPosition); + + [Fact] + public void CantJumpLoadedDown_Is0x49() + => Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown); + + /// + /// Every code in the A10 table in one pass — guards against a + /// future partial edit desyncing an individual test above from the + /// enum declaration. + /// + [Theory] + [InlineData(WeenieError.None, 0x00u)] + [InlineData(WeenieError.NoPhysicsObject, 0x08u)] + [InlineData(WeenieError.NotGrounded, 0x24u)] + [InlineData(WeenieError.CrouchInCombatStance, 0x3fu)] + [InlineData(WeenieError.SitInCombatStance, 0x40u)] + [InlineData(WeenieError.SleepInCombatStance, 0x41u)] + [InlineData(WeenieError.ChatEmoteOutsideNonCombat, 0x42u)] + [InlineData(WeenieError.ActionDepthExceeded, 0x45u)] + [InlineData(WeenieError.GeneralMovementFailure, 0x47u)] + [InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)] + [InlineData(WeenieError.CantJumpLoadedDown, 0x49u)] + public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected) + => Assert.Equal(expected, (uint)code); +}