diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs new file mode 100644 index 00000000..ab30efaa --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs @@ -0,0 +1,201 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager +/// port: heading_diff, heading_greater, Position::heading +/// / Frame::get_heading / Frame::set_heading, and +/// Position::cylinder_distance. No GL/App dependency — Core-only, +/// per the Code Structure Rules. NAME WATCH: this file (not +/// MoveToManager.cs, per r4-port-plan.md §3 "New code target") is the +/// R4-V1 deliverable; the manager itself is R4-V2. +/// +public static class MoveToMath +{ + /// + /// Universal heading/distance epsilon (same literal as R3's A5/A6 — + /// r4-moveto-decomp.md §12 constants inventory). + /// + public const float Epsilon = 0.000199999995f; + + /// + /// Retail heading_diff (0x00528fb0, free function, raw + /// 306327-306347), PINNED by direct disassembly of the PDB-matched + /// retail binary (ghidra-confirmations.md §P3 — the strongest evidence + /// tier in the R4 pin set): + /// + /// d = h1 - h2; + /// if (fabs(h1 - h2) < F_EPSILON) d = 0; + /// if (d < -F_EPSILON) d += 360; + /// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror + /// return d; + /// + /// The mirror gates on the turn command NOT being TurnRight + /// (0x6500000d) — TurnLeft (and any other command) measures the + /// COMPLEMENTARY angle. This CONTRADICTS r4-moveto-decomp.md §5g's + /// "arg3 UNUSED" claim, which the Ghidra disassembly pin overrides + /// (V0-pins.md §P3 adjudication). Call sites: BeginTurnToHeading + /// passes the CONSTANT TurnRight (mirror explicitly disabled — the + /// direction pick stays the ≤180 test elsewhere); HandleTurnToHeading + /// passes the LIVE current_command (can be TurnLeft). + /// + /// First heading, degrees. + /// Second heading, degrees. + /// The active turn command id — gates the mirror. + /// Normalized heading difference, degrees. + public static float HeadingDiff(float h1, float h2, uint turnCmd) + { + float d = h1 - h2; + if (MathF.Abs(h1 - h2) < Epsilon) + { + d = 0f; + } + if (d < -Epsilon) + { + d += 360f; + } + if (Epsilon < d && turnCmd != MotionCommand.TurnRight) + { + d = 360f - d; + } + return d; + } + + /// + /// Retail heading_greater (00528f60, free function, raw + /// 306281-306323), verbatim per r4-moveto-decomp.md §5f: + /// + /// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped + /// else greater = (a > b); + /// if (turnCmd == TurnRight) return greater; + /// return !greater; // TurnLeft (and any other cmd): inverted + /// + /// "Has the turn passed the target heading" — direction-aware, + /// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is + /// == TurnRight (not != TurnRight as in + /// 's mirror) — every OTHER command inverts, + /// not just TurnLeft specifically. + /// + public static bool HeadingGreater(float a, float b, uint turnCmd) + { + bool greater = MathF.Abs(a - b) > 180f + ? b > a + : a > b; + + return turnCmd == MotionCommand.TurnRight ? greater : !greater; + } + + /// + /// Retail Position::heading(from, to) (0x005a9520, raw + /// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 = + /// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360). + /// + /// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360 + /// + /// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270. + /// Horizontal (X/Y) only — Z (height) does not participate, matching + /// retail's compass-heading semantics. An in-tree twin of this formula + /// already exists at SceneryHelpers.cs:75 (render-side, + /// independently verified — not reused directly to keep this file + /// GL-free per the Code Structure Rules, but the formula is identical). + /// + public static float PositionHeading(Vector3 from, Vector3 to) + { + float dx = to.X - from.X; + float dy = to.Y - from.Y; + float headingDeg = 450f - MathF.Atan2(dy, dx) * (180f / MathF.PI); + headingDeg %= 360f; + if (headingDeg < 0f) headingDeg += 360f; + return headingDeg; + } + + /// + /// Retail Frame::get_heading (0x00535760, raw 319781) — + /// extracts the compass heading (P5 convention) from a body orientation + /// quaternion. The packer-reuse trap (V0-pins §P5 correction): + /// acdream's outbound packer (GameWindow.YawToAcQuaternion) is + /// wire-correct at the QUATERNION level but its internal scalar + /// intermediate (headingDeg = 180 - yawDeg) is holtburger's + /// SHIFTED convention, not retail's. This method uses the CORRECT + /// scalar bridge derived from acdream's own body convention + /// (PlayerMovementController.cs:1022-1025: Orientation = + /// AxisAngle(Z, Yaw - PI/2), local-forward = +Y, Yaw=0 faces +X): + /// world-forward = (cos Yaw, sin Yaw), so + /// YawDeg = atan2Deg(forward.Y, forward.X) and + /// heading = (90 - YawDeg) mod 360 — the exact inverse of + /// . Identity quaternion (Yaw=PI/2, i.e. facing + /// +Y/North) → heading 0, matching P5's "identity quaternion faces + /// heading 0" pin. + /// + public static float GetHeading(Quaternion orientation) + { + var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), orientation); + float yawDeg = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI); + float headingDeg = 90f - yawDeg; + headingDeg %= 360f; + if (headingDeg < 0f) headingDeg += 360f; + return headingDeg; + } + + /// + /// Retail Frame::set_heading (0x00535e40, raw + /// 320055-320066) — builds a body orientation quaternion facing + /// (P5 compass convention), preserving + /// acdream's body-orientation convention (rotation about world Z only; + /// 's pitch/roll, if any, is + /// discarded — matching retail's set_heading, which is a pure + /// yaw-about-Z setter). Exact inverse of : + /// YawDeg = 90 - headingDeg, then Orientation = + /// AxisAngle(Z, Yaw - PI/2) per + /// PlayerMovementController.cs:1025's convention. + /// + /// Unused beyond signature parity with + /// the render-side SceneryHelpers.SetHeading twin — retail's + /// set_heading is a pure yaw-about-Z setter with no dependency + /// on the prior orientation's roll/pitch component in the body-frame + /// convention this port uses. + /// Desired compass heading, degrees. + public static Quaternion SetHeading(Quaternion baseOrientation, float headingDeg) + { + _ = baseOrientation; + float yawDeg = 90f - headingDeg; + float yaw = yawDeg * (MathF.PI / 180f); + return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f); + } + + /// + /// Retail Position::cylinder_distance, the pure-math shape + /// consumed by MoveToManager::GetCurrentDistance + /// (005291b0, r4-moveto-decomp.md §5a) when use_spheres + /// (wire bit 0x400) is set — object moves use edge-to-edge cylinder + /// distance; position moves use plain center (Euclidean) distance + /// instead (not ported here — Vector3.Distance already covers + /// it). BN garbles the x87 plumbing in the raw, so the exact + /// radius-combination arithmetic is not directly visible; ported per + /// the PDB argument ORDER (own radius/height/position, target + /// radius/height/position) with the standard cylinder-distance shape: + /// planar (X/Y) center distance minus the sum of both radii, clamped + /// at zero (overlapping cylinders report 0, never negative). Height is + /// accepted for signature parity with the retail call (own/target + /// height feed the caller's contact-plane logic elsewhere) but does + /// NOT participate in this edge-to-edge planar computation — matching + /// retail's use of cylinder_distance purely for the horizontal arrival + /// gate. + /// + public static float CylinderDistance( + float ownRadius, float ownHeight, Vector3 ownPos, + float targetRadius, float targetHeight, Vector3 targetPos) + { + _ = ownHeight; + _ = targetHeight; + + float dx = targetPos.X - ownPos.X; + float dy = targetPos.Y - ownPos.Y; + float centerDist = MathF.Sqrt(dx * dx + dy * dy); + + float edgeDist = centerDist - ownRadius - targetRadius; + return edgeDist > 0f ? edgeDist : 0f; + } +} diff --git a/src/AcDream.Core/Physics/Motion/MovementParameters.cs b/src/AcDream.Core/Physics/Motion/MovementParameters.cs index 798157dd..8e648f55 100644 --- a/src/AcDream.Core/Physics/Motion/MovementParameters.cs +++ b/src/AcDream.Core/Physics/Motion/MovementParameters.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Physics; + namespace AcDream.Core.Physics.Motion; /// @@ -184,4 +186,287 @@ public sealed class MovementParameters /// Retail default 0. public uint ActionStamp { get; set; } + + // ── R4-V1: command-selection family (closes M2-mechanics) ───────────── + + /// + /// Retail MovementParameters::get_command (0x0052aa00, + /// raw 307946-308012), VERBATIM per + /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Picks the + /// motion command + moving-away flag from the towards/away bitfield + /// combination, THEN the walk-vs-run cascade. + /// + /// + /// Command pick (mirrors towards_and_away's bands but is + /// NOT identical — see the asymmetry note on ): + /// + /// MoveTowards && MoveAway → delegate + /// to (the three-band form). + /// MoveTowards only (or neither flag set — + /// retail's else if falls through to the SAME branch): plain + /// towards — dist > DistanceToObject → WalkForward, + /// !movingAway; else idle (cmd 0). + /// MoveAway only: pure away — + /// dist < MinDistance → WalkForward, movingAway=true (the + /// heading flips +180 via — turn-around, + /// NOT WalkBackwards, unlike 's min-band); + /// else idle. + /// + /// + /// + /// + /// THE walk-vs-run rule (confirms + /// feedback_autowalk_cancharge_bit — port RETAIL's version of + /// BOTH the fast-path ACE dropped and the threshold-close-walk pair): + /// HoldKey.RunCanCharge set (the fast-path — wins + /// regardless of CanRun/CanWalk/distance), OR (CanRun set AND + /// (CanWalk clear OR dist - DistanceToObject > + /// WalkRunThreshhold)). HoldKey.None (walk) ⇐ no CanRun, or + /// walk-capable within the threshold (INCLUSIVE ≤ — the raw's + /// test ah,0x41 after the fcom is the not-greater-than reading, + /// §5c @308003). + /// + /// + /// Current distance-to-target (retail's + /// GetCurrentDistance result — center or cylinder distance per + /// ). + /// Heading-to-target minus current heading, + /// normalized [0,360) — UNUSED by get_command itself (the raw + /// signature carries it for parity with the caller's local; retail's + /// body never reads arg3 in this build). Kept as a parameter for + /// call-site symmetry with BeginMoveForward (§4c), which computes + /// it immediately before calling get_command. + /// Chosen motion command id, or 0 if no movement + /// is needed (already in range). + /// Chosen hold key (walk vs run). + /// True if the chosen motion moves the mover + /// AWAY from the target (feeds and the + /// arrival predicate's polarity). + public void GetCommand(float dist, float headingDiff, out uint motion, out HoldKey holdKey, out bool movingAway) + { + _ = headingDiff; // retail's arg3 — unread in this build's body (§5c) + + // ── command + moving_away pick ────────────────────────────────── + if (MoveTowards && MoveAway) + { + TowardsAndAway(dist, out motion, out movingAway); + } + else if (MoveAway && !MoveTowards) + { + // pure AWAY: dist < min_distance → WalkForward, moving away + // (turn-around; heading flips +180 via GetDesiredHeading). + if (dist < MinDistance) + { + motion = MotionCommand.WalkForward; + movingAway = true; + } + else + { + motion = 0u; + movingAway = false; + } + } + else + { + // plain TOWARDS (MoveTowards set, or neither flag set — retail's + // `else if ((flags & 0x100) == 0)` falls to the same label). + if (dist > DistanceToObject) + { + motion = MotionCommand.WalkForward; + movingAway = false; + } + else + { + motion = 0u; + movingAway = false; + } + } + + // ── walk-vs-run HoldKey cascade ───────────────────────────────── + if (CanCharge) + { + // THE fast-path ACE dropped: can_charge short-circuits straight + // to Run regardless of CanRun/CanWalk/distance. + holdKey = HoldKey.Run; + return; + } + if (!CanRun) + { + holdKey = HoldKey.None; + return; + } + if (CanWalk && (dist - DistanceToObject) <= WalkRunThreshhold) + { + holdKey = HoldKey.None; + return; + } + holdKey = HoldKey.Run; + } + + /// + /// Retail MovementParameters::towards_and_away + /// (0x0052a9a0, raw 307917-307942), VERBATIM per + /// r4-moveto-decomp.md §5d. Three bands: + /// + /// dist > DistanceToObject → WalkForward, + /// towards (not moving away). + /// dist - MinDistance < F_EPSILON (inside + /// the min-distance band) → WalkBackward, moving away. NOTE the + /// asymmetry vs 's pure-away branch: this backs + /// up with WalkBackwards (no turn-around), not WalkForward+heading-flip + /// (r4-moveto-decomp.md :656). + /// otherwise (strictly inside [MinDistance, + /// DistanceToObject]) → idle (cmd 0). + /// + /// + /// Current distance-to-target. + /// Chosen motion command, or 0 if already in the dead + /// band. + /// True only for the WalkBackward (min-band) + /// case. + public void TowardsAndAway(float dist, out uint cmd, out bool movingAway) + { + const float epsilon = 0.000199999995f; + + if (dist > DistanceToObject) + { + cmd = MotionCommand.WalkForward; + movingAway = false; + return; + } + if (dist - MinDistance < epsilon) + { + cmd = MotionCommand.WalkBackward; + movingAway = true; + return; + } + cmd = 0u; + movingAway = false; + } + + /// + /// Retail MovementParameters::get_desired_heading + /// (0x0052aad0), PINNED by direct Ghidra decompile of + /// patchmem.gpr (fetched live during V0 — see + /// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2, + /// ACE-shaped constants CONFIRMED exact, superseding the earlier + /// BN-garble-based "high confidence" pin): + /// + /// __thiscall get_desired_heading(command, movingAway) + /// { + /// if (command == RunForward || command == WalkForward) { + /// if (!movingAway) return 0.0f; + /// } else { + /// if (command != WalkBackward) return 0.0f; + /// if (movingAway) return 0.0f; + /// } + /// return 180.0f; + /// } + /// + /// Truth table: forward/run + towards → 0°; forward/run + away → 180°; + /// backward + towards → 180°; backward + away → 0°; any other command + /// → 0°. This is the heading OFFSET added to the raw heading-to-target + /// so an "away" walk faces away and an "away" backstep faces the + /// target. + /// + public float GetDesiredHeading(uint command, bool movingAway) + { + if (command == MotionCommand.RunForward || command == MotionCommand.WalkForward) + { + if (!movingAway) return 0f; + } + else + { + if (command != MotionCommand.WalkBackward) return 0f; + if (movingAway) return 0f; + } + return 180f; + } + + // ── R4-V1: wire factory (closes M15/wire-exposure groundwork) ───────── + + /// + /// Factory for the retail MovementParameters::UnPackNet 7-dword + /// MoveTo wire form (0x0052ac50, 0x1c bytes, raw 308118-308205 — + /// r4-moveto-decomp.md §2g): bitfield, distance_to_object, + /// min_distance, fail_distance, speed, walk_run_threshhold, + /// desired_heading. Used by MoveToObject (type 6) and + /// MoveToPosition (type 7) wire payloads — the SAME field order + /// UpdateMotion.TryParseMoveToPayload already reads + /// (UpdateMotion.cs:328-341). The bitfield fully OVERWRITES every named + /// flag (UnPackNet does not merge with ctor defaults — every bit not + /// present in resolves to false); the wire + /// bitfield carries can_charge (0x10), the walk-vs-run answer + /// consumed by (cross-ref + /// feedback_autowalk_cancharge_bit). + /// + public static MovementParameters FromWire( + uint bitfield, + float distanceToObject, + float minDistance, + float failDistance, + float speed, + float walkRunThreshhold, + float desiredHeading) + { + var p = new MovementParameters(); + ApplyBitfield(p, bitfield); + p.DistanceToObject = distanceToObject; + p.MinDistance = minDistance; + p.FailDistance = failDistance; + p.Speed = speed; + p.WalkRunThreshhold = walkRunThreshhold; + p.DesiredHeading = desiredHeading; + return p; + } + + /// + /// Factory for the retail MovementParameters::UnPackNet 3-dword + /// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): bitfield, + /// speed, desired_heading. Used by TurnToObject (type 8) and + /// TurnToHeading (type 9) wire payloads. Distance-related scalars + /// (DistanceToObject/MinDistance/FailDistance/ + /// WalkRunThreshhold) are NOT on this wire form and keep the + /// ctor defaults — retail's UnPackNet + /// for this form only ever writes the three fields named here. + /// + public static MovementParameters FromWireTurnTo( + uint bitfield, + float speed, + float desiredHeading) + { + var p = new MovementParameters(); + ApplyBitfield(p, bitfield); + p.Speed = speed; + p.DesiredHeading = desiredHeading; + return p; + } + + /// + /// Decode the A4-pinned bitfield masks onto the named bool properties. + /// Shared by / — the + /// wire bitfield always fully overwrites (retail's UnPackNet assigns + /// the raw dword straight into this->bitfield, no merge). + /// + private static void ApplyBitfield(MovementParameters p, uint bitfield) + { + p.CanWalk = (bitfield & 0x1u) != 0; + p.CanRun = (bitfield & 0x2u) != 0; + p.CanSidestep = (bitfield & 0x4u) != 0; + p.CanWalkBackwards = (bitfield & 0x8u) != 0; + p.CanCharge = (bitfield & 0x10u) != 0; + p.FailWalk = (bitfield & 0x20u) != 0; + p.UseFinalHeading = (bitfield & 0x40u) != 0; + p.Sticky = (bitfield & 0x80u) != 0; + p.MoveAway = (bitfield & 0x100u) != 0; + p.MoveTowards = (bitfield & 0x200u) != 0; + p.UseSpheres = (bitfield & 0x400u) != 0; + p.SetHoldKey = (bitfield & 0x800u) != 0; + p.Autonomous = (bitfield & 0x1000u) != 0; + p.ModifyRawState = (bitfield & 0x2000u) != 0; + p.ModifyInterpretedState = (bitfield & 0x4000u) != 0; + p.CancelMoveTo = (bitfield & 0x8000u) != 0; + p.StopCompletelyFlag = (bitfield & 0x10000u) != 0; + p.DisableJumpDuringLink = (bitfield & 0x20000u) != 0; + } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index a31cc37b..a0048089 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -95,11 +95,31 @@ public static class MotionCommand } /// -/// Movement type passed in PerformMovement's switch statement. -/// Matches the 5-case switch at FUN_00529a90. +/// Movement type passed in PerformMovement's switch statement. Matches +/// retail's MovementTypes::Type (acclient.h:2856, enum #229) in full. +/// +/// +/// R4-V1 widening (closes M11). Values 1-5 dispatch through +/// CMotionInterp (the 5-case switch at FUN_00529a90, unchanged since +/// R1-R3); values 6-9 dispatch through MoveToManager +/// (MovementManager::PerformMovement, r4-moveto-decomp.md §2b: +/// (type - 1) > 8 → 0x47, case 0-4 → CMotionInterp, case 5-8 → +/// MoveToManager). Invalid=0 and values > 9 both fail with +/// (0x47) at the +/// MovementManager level — no consumer wiring changes in this slice +/// (mechanical, additive-only; MoveToManager itself is R4-V2+). +/// /// public enum MovementType { + /// + /// 0 — no movement in progress / uninitialized. R4-V1 addition (M11). + /// MoveToManager::InitializeLocalVariables resets + /// movement_type to this value; MovementManager::PerformMovement + /// rejects it with 0x47 (§2b: (type - 1) > 8 underflows to a + /// huge unsigned value for type 0, which is always > 8). + /// + Invalid = 0, /// case 1 — raw motion command (DoMotion). RawCommand = 1, /// case 2 — interpreted motion command (DoInterpretedMotion). @@ -110,6 +130,31 @@ public enum MovementType StopInterpretedCommand = 4, /// case 5 — stop completely (StopCompletely). StopCompletely = 5, + /// + /// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to + /// MoveToManager::MoveToObject (r4-moveto-decomp.md §3b); uses + /// // + /// /. + /// + MoveToObject = 6, + /// + /// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to + /// MoveToManager::MoveToPosition (§3c); uses + /// . + /// + MoveToPosition = 7, + /// + /// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to + /// MoveToManager::TurnToObject (§3d); uses + /// /. + /// + TurnToObject = 8, + /// + /// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to + /// MoveToManager::TurnToHeading (§3e); uses + /// 's DesiredHeading. + /// + TurnToHeading = 9, } /// @@ -144,6 +189,15 @@ public enum WeenieError : uint /// NoPhysicsObject = 0x08, /// + /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per + /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants + /// inventory (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). ACE name; + /// the retail sites that store this code were not individually + /// extracted in the R4 pass (no MoveToManager consumer in this slice — + /// V1 pins the numeric value only). + /// + NoMotionInterpreter = 0x0B, + /// /// 0x24 — not grounded / no contact. Sites (A10): /// jump_is_allowed @305570 (gravity-active creature without /// Contact+OnWalkable; also the physics_obj == null case, which @@ -173,6 +227,30 @@ public enum WeenieError : uint /// ChatEmoteOutsideNonCombat = 0x42, /// + /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site: + /// MoveToManager::PerformMovement (r4-moveto-decomp.md §3a + /// @0052a901) — every new moveto cancels the previous one with this + /// code before dispatching; also CPhysicsObj::interrupt_current_movement + /// → MovementManager::CancelMoveTo(0x36) (§9e — the TS-36 cancel + /// entry). Per §7c, MoveToManager::CancelMoveTo's WeenieError arg + /// is NEVER READ in this build's body — kept for parity/logging only. + /// + ActionCancelled = 0x36, + /// + /// 0x37 — ObjectGone. R4-V1 addition (M12). Site: + /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — a + /// RETARGET delivery arrives with a non-OK target status (the target + /// object was already being tracked, then went away). + /// + ObjectGone = 0x37, + /// + /// 0x38 — NoObject. R4-V1 addition (M12). Site: + /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the + /// FIRST target callback arrives with a non-OK status (the target never + /// resolved in the first place). + /// + NoObject = 0x38, + /// /// 0x45 — action-queue depth cap: an action-class motion (bit /// 0x10000000) with GetNumActions() >= 6 pending. Site: /// DoMotion @306209. @@ -203,6 +281,17 @@ public enum WeenieError : uint /// @304941; charge_jump @305454. /// CantJumpLoadedDown = 0x49, + /// + /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site: + /// MoveToManager::HandleMoveToPosition Phase 2 arrival check + /// (r4-moveto-decomp.md §6b) — the fail_distance progress gate + /// exceeded (CheckProgressMade §5b failing for >1s AND the + /// mover overshot fail_distance). NOTE: numerically out of A10's + /// increasing order (0x3D < 0x3F/0x40/0x41/0x42/0x45) because it was + /// not part of the CMotionInterp jump-family sweep this code sits + /// beside — it belongs to the MoveToManager family instead (§7c, §12). + /// + YouChargedTooFar = 0x3D, } // ── Motion state structs ─────────────────────────────────────────────────────── @@ -404,12 +493,40 @@ public struct InterpretedMotionState /// /// Lightweight struct passed into PerformMovement. /// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct). +/// +/// +/// R4-V1 widening (closes M11). Retail's full MovementStruct +/// (acclient.h:38069, struct #4067): +/// +/// struct __cppobj MovementStruct +/// { +/// MovementTypes::Type type; +/// unsigned int motion; // types 1-4 only +/// unsigned int object_id; // types 6, 8 +/// unsigned int top_level_id; // types 6, 8 +/// Position pos; // type 7 +/// float radius; // type 6 +/// float height; // type 6 +/// MovementParameters *params; // types 1-4, 6-9 +/// }; +/// +/// /// +/// // are the +/// R4-V1 additions — additive only, no consumer wiring in this slice +/// (MoveToManager itself is R4-V2). The pre-R4 fields (/ +/// /// +/// /) are +/// untouched. uses acdream's +/// (ObjCellId + CellFrame) rather than retail's block-local Position — +/// V0-pins.md §P5: distances are equivalent after rebase in acdream's +/// streaming-world space. +/// /// public struct MovementStruct { - /// Which of the 5 motion types to dispatch. + /// Which movement type to dispatch (retail MovementTypes::Type, full 0-9 range). public MovementType Type; - /// Motion command ID (e.g. WalkForward). + /// Motion command ID (e.g. WalkForward). Types 1-4 only. public uint Motion; /// Speed scalar for this motion. public float Speed; @@ -419,6 +536,35 @@ public struct MovementStruct public bool ModifyInterpretedState; /// Whether to modify the raw state. public bool ModifyRawState; + + /// + /// R4-V1 — retail object_id. Types 6 (MoveToObject), 8 + /// (TurnToObject) only. + /// + public uint ObjectId; + /// + /// R4-V1 — retail top_level_id. Types 6 (MoveToObject), 8 + /// (TurnToObject) only. + /// + public uint TopLevelId; + /// + /// R4-V1 — retail pos (world position + cell). Type 7 + /// (MoveToPosition) only. + /// + public Position Pos; + /// + /// R4-V1 — retail radius. Type 6 (MoveToObject) only. + /// + public float Radius; + /// + /// R4-V1 — retail height. Type 6 (MoveToObject) only. + /// + public float Height; + /// + /// R4-V1 — retail params (a pointer in retail; a reference + /// here). Types 1-4 and 6-9. + /// + public Motion.MovementParameters? Params; } // ── Optional WeenieObject interface ────────────────────────────────────────── diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs new file mode 100644 index 00000000..eb21d692 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs @@ -0,0 +1,91 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — Position::cylinder_distance, the pure-math shape per +/// r4-moveto-decomp.md §5a (MoveToManager::GetCurrentDistance, +/// 005291b0): edge-to-edge distance between two vertical cylinders +/// (own radius/height, target radius/height, both positions). Object moves +/// (use_spheres set on the wire) use this; position moves use plain +/// Euclidean Position::distance (§5a: "position moves use center +/// distance" — is the object-move +/// variant only; center distance is Vector3.Distance, already +/// available, not re-ported here). +/// +/// +/// The retail signature's exact combination math for radius/height beyond +/// "edge-to-edge, own+target cylinders" is not spelled out in the raw (BN +/// garbles the x87 plumbing) — ported per the PDB argument ORDER +/// (own radius/height, own position, target radius/height, target +/// position) with the standard cylinder-distance shape: horizontal +/// (planar) distance minus the sum of the two radii (clamped at 0), since +/// that is the only shape consistent with "edge-to-edge" and with +/// distance_to_object's ctor default of 0.6 (melee range from +/// surface to surface, not center to center). +/// +/// +public sealed class MoveToMathCylinderDistanceTests +{ + [Fact] + public void TwoCylinders_HorizontallySeparated_SubtractsBothRadii() + { + // centers 10 units apart on X, radii 1 and 2 → edge distance = 10-1-2=7 + float d = MoveToMath.CylinderDistance( + ownRadius: 1f, ownHeight: 2f, ownPos: Vector3.Zero, + targetRadius: 2f, targetHeight: 2f, targetPos: new Vector3(10f, 0f, 0f)); + + Assert.Equal(7f, d, 3); + } + + [Fact] + public void TwoCylinders_Overlapping_ClampsAtZero_NoNegativeDistance() + { + // centers 1 unit apart, radii 5 and 5 → would be -9, clamps to 0 + float d = MoveToMath.CylinderDistance( + ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero, + targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f)); + + Assert.Equal(0f, d, 3); + } + + [Fact] + public void TwoCylinders_ZeroRadii_ReducesToCenterDistance() + { + float d = MoveToMath.CylinderDistance( + ownRadius: 0f, ownHeight: 2f, ownPos: Vector3.Zero, + targetRadius: 0f, targetHeight: 2f, targetPos: new Vector3(3f, 4f, 0f)); + + Assert.Equal(5f, d, 3); // 3-4-5 triangle + } + + [Fact] + public void TwoCylinders_IgnoresVerticalSeparation_PlanarOnly() + { + // Same X/Y, large Z separation — cylinder_distance in retail's own + // callers (GetCurrentDistance) is used for horizontal arrival gates; + // the Z axis is height, not part of the radial edge-to-edge gap. + float d1 = MoveToMath.CylinderDistance( + ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 0), + targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, 0f)); + float d2 = MoveToMath.CylinderDistance( + ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 50f), + targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, -50f)); + + Assert.Equal(d1, d2, 3); + Assert.Equal(3f, d1, 3); // 5 - 1 - 1 + } + + [Fact] + public void SamePosition_ZeroDistance_ClampsNotNegative() + { + float d = MoveToMath.CylinderDistance( + ownRadius: 0.5f, ownHeight: 2f, ownPos: Vector3.Zero, + targetRadius: 0.5f, targetHeight: 2f, targetPos: Vector3.Zero); + + Assert.Equal(0f, d, 3); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs new file mode 100644 index 00000000..1159642b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs @@ -0,0 +1,164 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — heading_diff (0x00528fb0), PINNED by direct +/// disassembly of the PDB-matched retail binary (see +/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P3 — this is +/// the strongest evidence tier in the whole R4 pin set, one level above a +/// Ghidra decompile). Verbatim body: +/// +/// d = h1 - h2; +/// if (fabs(h1 - h2) < F_EPSILON) d = 0; +/// if (d < -F_EPSILON) d += 360; +/// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror +/// return d; +/// +/// F_EPSILON = 0.000199999995f. The mirror gates on the turn command being +/// NOT TurnRight (0x6500000d) — TurnLeft (and any other command) measures +/// the COMPLEMENTARY angle. This contradicts r4-moveto-decomp.md §5g's +/// "arg3 UNUSED" claim, which the Ghidra pin overrides (adjudicated in +/// V0-pins.md). +/// +public sealed class MoveToMathHeadingDiffTests +{ + private const float Eps = 0.000199999995f; + private const uint TurnRight = MotionCommand.TurnRight; + private const uint TurnLeft = MotionCommand.TurnLeft; + + // ── basic subtraction, TurnRight (no mirror) ─────────────────────────── + + [Fact] + public void TurnRight_SimplePositiveDiff_NoWrap() + { + float d = MoveToMath.HeadingDiff(90f, 30f, TurnRight); + Assert.Equal(60f, d, 3); + } + + [Fact] + public void TurnRight_NegativeDiff_WrapsBy360() + { + // h1 - h2 = 30 - 90 = -60 → wraps to 300 + float d = MoveToMath.HeadingDiff(30f, 90f, TurnRight); + Assert.Equal(300f, d, 3); + } + + [Fact] + public void TurnRight_ZeroDiff_IsZero() + { + float d = MoveToMath.HeadingDiff(45f, 45f, TurnRight); + Assert.Equal(0f, d, 3); + } + + // ── epsilon boundary (both sides) ────────────────────────────────────── + + [Fact] + public void EpsilonBoundary_ExactlyAtEpsilon_NotSnappedToZero() + { + // fabs(d) < EPSILON is a STRICT less-than — exactly at epsilon does + // NOT snap to zero. + float d = MoveToMath.HeadingDiff(Eps, 0f, TurnRight); + Assert.NotEqual(0f, d); + Assert.Equal(Eps, d, 6); + } + + [Fact] + public void EpsilonBoundary_JustBelowEpsilon_SnapsToZero() + { + float d = MoveToMath.HeadingDiff(Eps * 0.5f, 0f, TurnRight); + Assert.Equal(0f, d); + } + + [Fact] + public void EpsilonBoundary_NegativeJustBelowNegEpsilon_WrapsBy360() + { + // d = -Eps * 1.5 < -Eps → wraps + float raw = -Eps * 1.5f; + float d = MoveToMath.HeadingDiff(raw, 0f, TurnRight); + Assert.Equal(raw + 360f, d, 3); + } + + [Fact] + public void EpsilonBoundary_NegativeExactlyAtNegEpsilon_DoesNotWrap() + { + // d < -EPSILON is STRICT — exactly -EPSILON does not wrap. + float d = MoveToMath.HeadingDiff(-Eps, 0f, TurnRight); + Assert.Equal(-Eps, d, 6); + } + + // ── the mirror (TurnLeft / not-TurnRight) ────────────────────────────── + + [Fact] + public void TurnLeft_PositiveDiffAboveEpsilon_MirroredTo360MinusD() + { + float d = MoveToMath.HeadingDiff(90f, 30f, TurnLeft); + // raw = 60; mirror: 360 - 60 = 300 + Assert.Equal(300f, d, 3); + } + + [Fact] + public void TurnLeft_NegativeDiff_WrapsThenMirrors() + { + // h1-h2 = 30-90 = -60 → wraps to 300 → mirror gate (300 > EPS, not + // TurnRight) → 360 - 300 = 60 + float d = MoveToMath.HeadingDiff(30f, 90f, TurnLeft); + Assert.Equal(60f, d, 3); + } + + [Fact] + public void TurnLeft_ZeroDiff_MirrorGateDoesNotFire_StaysZero() + { + // d == 0 does not satisfy `d > EPSILON`, so the mirror never fires + // regardless of turn command. + float d = MoveToMath.HeadingDiff(45f, 45f, TurnLeft); + Assert.Equal(0f, d); + } + + [Fact] + public void TurnLeft_AtEpsilonBoundary_MirrorGateIsStrictGreaterThan() + { + // d > EPSILON is STRICT: exactly at EPSILON does NOT mirror. + float d = MoveToMath.HeadingDiff(Eps, 0f, TurnLeft); + Assert.Equal(Eps, d, 6); + } + + [Fact] + public void TurnLeft_JustAboveEpsilon_Mirrors() + { + float raw = Eps * 2f; + float d = MoveToMath.HeadingDiff(raw, 0f, TurnLeft); + Assert.Equal(360f - raw, d, 3); + } + + [Fact] + public void AnyNonTurnRightCommand_AlsoMirrors() + { + // The gate is "!= TurnRight", not "== TurnLeft" — any other command + // (e.g. 0, WalkForward) also triggers the mirror. + float d = MoveToMath.HeadingDiff(90f, 30f, 0u); + Assert.Equal(300f, d, 3); + + float d2 = MoveToMath.HeadingDiff(90f, 30f, MotionCommand.WalkForward); + Assert.Equal(300f, d2, 3); + } + + // ── 360-wrap combined with the mirror ────────────────────────────────── + + [Fact] + public void TurnRight_FullCircleInputs_NormalizeCorrectly() + { + float d = MoveToMath.HeadingDiff(350f, 10f, TurnRight); + Assert.Equal(340f, d, 3); + } + + [Fact] + public void TurnLeft_FullCircleInputs_MirroredAfterNormalize() + { + // raw = 350-10 = 340 (no wrap needed, positive); mirror: 360-340=20 + float d = MoveToMath.HeadingDiff(350f, 10f, TurnLeft); + Assert.Equal(20f, d, 3); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs new file mode 100644 index 00000000..7ab6fdff --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs @@ -0,0 +1,82 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — heading_greater (00528f60, raw 306281-306323), per +/// r4-moveto-decomp.md §5f: +/// +/// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped +/// else greater = (a > b); +/// if (turnCmd == TurnRight) return greater; +/// return !greater; // TurnLeft: inverted +/// +/// "Has the turn passed the target heading" — direction-aware, wrap-aware. +/// +public sealed class MoveToMathHeadingGreaterTests +{ + private const uint TurnRight = MotionCommand.TurnRight; + private const uint TurnLeft = MotionCommand.TurnLeft; + + [Fact] + public void TurnRight_UnwrappedCase_SimpleGreater() + { + Assert.True(MoveToMath.HeadingGreater(90f, 30f, TurnRight)); + Assert.False(MoveToMath.HeadingGreater(30f, 90f, TurnRight)); + } + + [Fact] + public void TurnRight_WrappedCase_ComparisonFlips() + { + // |350 - 10| = 340 > 180 → wrapped: greater = (b > a) = (10 > 350) = false + Assert.False(MoveToMath.HeadingGreater(350f, 10f, TurnRight)); + // |10 - 350| = 340 > 180 → wrapped: greater = (b > a) = (350 > 10) = true + Assert.True(MoveToMath.HeadingGreater(10f, 350f, TurnRight)); + } + + [Fact] + public void TurnRight_ExactlyAt180Delta_UnwrappedBranch() + { + // fabs(a-b) > 180 is STRICT — exactly 180 uses the unwrapped branch. + // a=200,b=20: fabs=180, not >180 → greater = (a>b) = true + Assert.True(MoveToMath.HeadingGreater(200f, 20f, TurnRight)); + } + + [Fact] + public void TurnLeft_InvertsTheUnwrappedResult() + { + Assert.False(MoveToMath.HeadingGreater(90f, 30f, TurnLeft)); + Assert.True(MoveToMath.HeadingGreater(30f, 90f, TurnLeft)); + } + + [Fact] + public void TurnLeft_InvertsTheWrappedResult() + { + Assert.True(MoveToMath.HeadingGreater(350f, 10f, TurnLeft)); + Assert.False(MoveToMath.HeadingGreater(10f, 350f, TurnLeft)); + } + + [Fact] + public void EqualHeadings_NotGreater_TurnRight() + { + Assert.False(MoveToMath.HeadingGreater(45f, 45f, TurnRight)); + } + + [Fact] + public void EqualHeadings_InvertedToTrue_TurnLeft() + { + // greater=false for equal headings; TurnLeft inverts → true. + Assert.True(MoveToMath.HeadingGreater(45f, 45f, TurnLeft)); + } + + [Fact] + public void AnyNonTurnRightCommand_AlsoInverts() + { + // The retail gate is `== TurnRight` (not `!= TurnRight` as in + // heading_diff) — every OTHER command, not just TurnLeft, inverts. + Assert.False(MoveToMath.HeadingGreater(90f, 30f, 0u)); + Assert.False(MoveToMath.HeadingGreater(90f, 30f, MotionCommand.WalkForward)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs new file mode 100644 index 00000000..cd9fe4f7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs @@ -0,0 +1,127 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — Position::heading / Frame::get_heading / +/// Frame::set_heading, per V0-pins.md §P5 (PINNED — compass degrees, +/// 0 = North (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion +/// faces heading 0): +/// +/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360 +/// +/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270. +/// +/// +/// The packer-reuse trap (V0-pins §P5 correction): acdream's +/// outbound packer (GameWindow.YawToAcQuaternion) is wire-correct at +/// the QUATERNION level but its internal scalar intermediate +/// (headingDeg = 180 - yawDeg) is holtburger's shifted convention, +/// NOT retail's wire convention. must +/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per +/// PlayerMovementController.cs:1022-1025): heading = (90 - +/// yawDeg) mod 360 — NOT 180 - yawDeg. +/// +/// +public sealed class MoveToMathPositionHeadingTests +{ + private const float Tol = 0.01f; + + // ── PositionHeading: the four cardinal offsets ───────────────────────── + + [Fact] + public void North_PlusY_IsZero() + { + float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, 1f, 0f)); + Assert.Equal(0f, h, 2); + } + + [Fact] + public void East_PlusX_Is90() + { + float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 0f, 0f)); + Assert.Equal(90f, h, 2); + } + + [Fact] + public void South_MinusY_Is180() + { + float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, -1f, 0f)); + Assert.Equal(180f, h, 2); + } + + [Fact] + public void West_MinusX_Is270() + { + float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(-1f, 0f, 0f)); + Assert.Equal(270f, h, 2); + } + + [Fact] + public void Heading_IsAlways_InZeroToThreeSixtyRange() + { + // NE diagonal + float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 1f, 0f)); + Assert.InRange(h, 0f, 360f); + Assert.Equal(45f, h, 2); + } + + [Fact] + public void Heading_IgnoresZ_HorizontalOnly() + { + float h1 = MoveToMath.PositionHeading(new Vector3(0, 0, 5f), new Vector3(1f, 0f, -10f)); + float h2 = MoveToMath.PositionHeading(new Vector3(0, 0, -3f), new Vector3(1f, 0f, 100f)); + Assert.Equal(h1, h2, 2); + Assert.Equal(90f, h1, 2); + } + + // ── GetHeading: extracts heading from a body orientation quaternion ──── + + [Fact] + public void GetHeading_IdentityQuaternion_FacesHeadingZero() + { + // Identity quaternion → acdream yaw = 0 → +X-facing in our + // convention, which decodes to AC heading 90 per the corrected + // scalar bridge... BUT the identity quaternion in acdream's body + // frame corresponds to yaw = -PI/2 relative to +Y-forward (see + // PlayerMovementController.cs:1025: Orientation = AxisAngle(Yaw - + // PI/2)). GetHeading must invert that exact convention: identity + // orientation (no rotation applied) means Yaw=PI/2 was baked in, + // which is heading 0 — matching P5's "identity quaternion faces + // heading 0" pin. + float h = MoveToMath.GetHeading(Quaternion.Identity); + Assert.Equal(0f, h, 1); + } + + [Fact] + public void GetHeading_SetHeading_RoundTrips_Cardinals() + { + foreach (float heading in new[] { 0f, 90f, 180f, 270f, 45f, 359f }) + { + var q = MoveToMath.SetHeading(Quaternion.Identity, heading); + float back = MoveToMath.GetHeading(q); + float diff = MathF.Abs(back - heading); + if (diff > 180f) diff = 360f - diff; + Assert.True(diff < 0.5f, $"heading {heading} round-tripped to {back}"); + } + } + + [Fact] + public void SetHeading_North_ProducesForwardVectorFacingPlusY() + { + var q = MoveToMath.SetHeading(Quaternion.Identity, 0f); + var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q); + Assert.True(forward.Y > 0.9f, $"expected +Y forward, got {forward}"); + } + + [Fact] + public void SetHeading_East_ProducesForwardVectorFacingPlusX() + { + var q = MoveToMath.SetHeading(Quaternion.Identity, 90f); + var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q); + Assert.True(forward.X > 0.9f, $"expected +X forward, got {forward}"); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs new file mode 100644 index 00000000..6bc46f0d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs @@ -0,0 +1,186 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — MovementParameters::UnPackNet (0x0052ac50, raw +/// 308118-308205) factory semantics, per +/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §2g: the 7-dword +/// MoveTo wire form is bitfield, distance_to_object, min_distance, +/// fail_distance, speed, walk_run_threshhold, desired_heading — the SAME +/// field order UpdateMotion.TryParseMoveToPayload already reads off +/// the wire (UpdateMotion.cs:328-341). The A4 bitfield masks +/// (W0-pins.md §A4) decode into the named bool properties; every bit not +/// present on the wire bitfield resolves to false (UnPackNet fully +/// overwrites the bitfield — no ctor-default bits survive). +/// +public sealed class MovementParametersFromWireTests +{ + [Fact] + public void FromWire_AllBitsSet_EveryFlagTrue() + { + var p = MovementParameters.FromWire( + bitfield: 0x3FFFFu, // every A4 bit through 0x20000 + distanceToObject: 1f, + minDistance: 2f, + failDistance: 3f, + speed: 4f, + walkRunThreshhold: 5f, + desiredHeading: 6f); + + Assert.True(p.CanWalk); + Assert.True(p.CanRun); + Assert.True(p.CanSidestep); + Assert.True(p.CanWalkBackwards); + Assert.True(p.CanCharge); + Assert.True(p.FailWalk); + Assert.True(p.UseFinalHeading); + Assert.True(p.Sticky); + Assert.True(p.MoveAway); + Assert.True(p.MoveTowards); + Assert.True(p.UseSpheres); + Assert.True(p.SetHoldKey); + Assert.True(p.Autonomous); + Assert.True(p.ModifyRawState); + Assert.True(p.ModifyInterpretedState); + Assert.True(p.CancelMoveTo); + Assert.True(p.StopCompletelyFlag); + Assert.True(p.DisableJumpDuringLink); + } + + [Fact] + public void FromWire_ZeroBitfield_EveryFlagFalse_NoCtorDefaultsSurvive() + { + var p = MovementParameters.FromWire( + bitfield: 0u, + distanceToObject: 1f, minDistance: 2f, failDistance: 3f, + speed: 4f, walkRunThreshhold: 5f, desiredHeading: 6f); + + Assert.False(p.CanWalk); + Assert.False(p.CanRun); + Assert.False(p.CanSidestep); + Assert.False(p.CanWalkBackwards); + Assert.False(p.CanCharge); + Assert.False(p.MoveTowards); + Assert.False(p.UseSpheres); + Assert.False(p.SetHoldKey); + Assert.False(p.ModifyRawState); + Assert.False(p.ModifyInterpretedState); + Assert.False(p.CancelMoveTo); + Assert.False(p.StopCompletelyFlag); + } + + [Fact] + public void FromWire_CanChargeBit_DecodesIndependently() + { + // The wire bitfield carries can_charge 0x10 — the walk-vs-run answer + // (feedback_autowalk_cancharge_bit). Verify it round-trips on its own. + var p = MovementParameters.FromWire( + bitfield: 0x10u, + distanceToObject: 0f, minDistance: 0f, failDistance: 0f, + speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f); + + Assert.True(p.CanCharge); + Assert.False(p.CanWalk); + Assert.False(p.CanRun); + } + + [Theory] + [InlineData(0x1u)] + [InlineData(0x2u)] + [InlineData(0x4u)] + [InlineData(0x8u)] + [InlineData(0x10u)] + [InlineData(0x20u)] + [InlineData(0x40u)] + [InlineData(0x80u)] + [InlineData(0x100u)] + [InlineData(0x200u)] + [InlineData(0x400u)] + [InlineData(0x800u)] + [InlineData(0x1000u)] + [InlineData(0x2000u)] + [InlineData(0x4000u)] + [InlineData(0x8000u)] + [InlineData(0x10000u)] + [InlineData(0x20000u)] + public void FromWire_SingleBitMaskRoundTrips(uint mask) + { + var p = MovementParameters.FromWire( + bitfield: mask, + distanceToObject: 0f, minDistance: 0f, failDistance: 0f, + speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f); + + Assert.Equal(mask, ToBitfield(p)); + } + + [Fact] + public void FromWire_ScalarFields_CopiedInWireOrder() + { + var p = MovementParameters.FromWire( + bitfield: 0u, + distanceToObject: 1.5f, + minDistance: 2.5f, + failDistance: 3.5f, + speed: 4.5f, + walkRunThreshhold: 5.5f, + desiredHeading: 6.5f); + + Assert.Equal(1.5f, p.DistanceToObject); + Assert.Equal(2.5f, p.MinDistance); + Assert.Equal(3.5f, p.FailDistance); + Assert.Equal(4.5f, p.Speed); + Assert.Equal(5.5f, p.WalkRunThreshhold); + Assert.Equal(6.5f, p.DesiredHeading); + } + + [Fact] + public void FromWireTurnTo_ThreeDwordForm_LeavesDistanceFieldsAtDefault() + { + // TurnToObject/TurnToHeading wire form (0xc bytes, 3 dwords): + // bitfield, speed, desired_heading only. distance_to_object / + // min_distance / fail_distance / walk_run_threshhold are NOT on + // this wire form — the factory overload must not touch them + // (they keep the MovementParameters ctor defaults). + var p = MovementParameters.FromWireTurnTo( + bitfield: 0x2u, // can_run + speed: 2f, + desiredHeading: 90f); + + Assert.True(p.CanRun); + Assert.Equal(2f, p.Speed); + Assert.Equal(90f, p.DesiredHeading); + + // ctor defaults, untouched by the 3-dword form: + Assert.Equal(0.6f, p.DistanceToObject); + Assert.Equal(0f, p.MinDistance); + Assert.Equal(float.MaxValue, p.FailDistance); + Assert.Equal(15f, p.WalkRunThreshhold); + } + + private static uint ToBitfield(MovementParameters p) + { + 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; + return bitfield; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs new file mode 100644 index 00000000..885baa2f --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs @@ -0,0 +1,340 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — MovementParameters::get_command (0x0052aa00, raw +/// 307946-308012), verbatim per +/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Covers the +/// command/moving_away pick (plain-towards / plain-away / towards_and_away +/// delegate) crossed with the walk-vs-run HoldKey cascade, INCLUDING the +/// CanCharge 0x10 fast-path ACE dropped (feedback_autowalk_cancharge_bit) +/// and the walk_run_threshhold ≤-vs-< edge (retail: dist - dto ≤ +/// threshold → walk; the raw's `test ah,0x41` is the inclusive ≤ reading, +/// §5c @308003). +/// +public sealed class MovementParametersGetCommandTests +{ + // ── plain TOWARDS (move_towards set, move_away clear) ───────────────── + + [Fact] + public void PlainTowards_DistGreaterThanDto_WalkForward_NotMovingAway() + { + var p = new MovementParameters { DistanceToObject = 0.6f }; + // move_towards=true (default), move_away=false (default) + + p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out HoldKey holdKey, out bool movingAway); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.False(movingAway); + } + + [Fact] + public void PlainTowards_DistNotGreaterThanDto_Idle() + { + var p = new MovementParameters { DistanceToObject = 0.6f }; + + p.GetCommand(dist: 0.6f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(0u, motion); + Assert.False(movingAway); + } + + [Fact] + public void PlainTowards_DistLessThanDto_Idle() + { + var p = new MovementParameters { DistanceToObject = 0.6f }; + + p.GetCommand(dist: 0.1f, headingDiff: 0f, out uint motion, out _, out _); + + Assert.Equal(0u, motion); + } + + // ── pure AWAY (move_away set, move_towards clear) ───────────────────── + + [Fact] + public void PureAway_DistLessThanMinDistance_WalkForward_MovingAway() + { + var p = new MovementParameters + { + MoveTowards = false, + MoveAway = true, + MinDistance = 5f, + }; + + p.GetCommand(dist: 2f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.True(movingAway); + } + + [Fact] + public void PureAway_DistNotLessThanMinDistance_Idle() + { + var p = new MovementParameters + { + MoveTowards = false, + MoveAway = true, + MinDistance = 5f, + }; + + p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(0u, motion); + Assert.False(movingAway); + } + + // ── towards_and_away delegate (both move_towards AND move_away set) ─── + + [Fact] + public void TowardsAndAway_DistGreaterThanDto_DelegatesToWalkForwardTowards() + { + var p = new MovementParameters + { + MoveTowards = true, + MoveAway = true, + DistanceToObject = 0.6f, + MinDistance = 0.2f, + }; + + p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.False(movingAway); + } + + [Fact] + public void TowardsAndAway_InsideMinBand_WalkBackwards_MovingAway() + { + var p = new MovementParameters + { + MoveTowards = true, + MoveAway = true, + DistanceToObject = 0.6f, + MinDistance = 0.2f, + }; + + // dist - min_distance < epsilon → inside the min band + p.GetCommand(dist: 0.2f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(MotionCommand.WalkBackward, motion); + Assert.True(movingAway); + } + + [Fact] + public void TowardsAndAway_InsideDeadband_Idle() + { + var p = new MovementParameters + { + MoveTowards = true, + MoveAway = true, + DistanceToObject = 0.6f, + MinDistance = 0.2f, + }; + + // strictly inside [min, dto] — neither band fires + p.GetCommand(dist: 0.4f, headingDiff: 0f, out uint motion, out _, out _); + + Assert.Equal(0u, motion); + } + + // ── neither towards nor away (both clear) — falls to plain-towards path ── + + [Fact] + public void NeitherTowardsNorAway_FallsToPlainTowardsBranch() + { + var p = new MovementParameters + { + MoveTowards = false, + MoveAway = false, + DistanceToObject = 0.6f, + }; + + p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); + + Assert.Equal(MotionCommand.WalkForward, motion); + Assert.False(movingAway); + } + + // ── walk-vs-run HoldKey cascade ──────────────────────────────────────── + + [Fact] + public void HoldKey_CanChargeSet_AlwaysRun_FastPath() + { + // THE fast-path ACE dropped: can_charge (0x10) short-circuits + // straight to HoldKey_Run regardless of distance/threshold. + var p = new MovementParameters + { + CanCharge = true, + CanRun = false, // even with can_run CLEAR + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.Run, holdKey); + } + + [Fact] + public void HoldKey_CanRunClear_AlwaysWalk_RegardlessOfDistance() + { + var p = new MovementParameters + { + CanCharge = false, + CanRun = false, + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + p.GetCommand(dist: 1000f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.None, holdKey); + } + + [Fact] + public void HoldKey_CanRunSet_CanWalkClear_AlwaysRun_WalkIncapable() + { + // can_walk clear → the "close enough to walk" branch is skipped + // entirely; walk-incapable movers always run when can_run is set. + var p = new MovementParameters + { + CanCharge = false, + CanRun = true, + CanWalk = false, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.Run, holdKey); + } + + [Fact] + public void HoldKey_CanRunAndCanWalk_WithinThreshold_Walk() + { + var p = new MovementParameters + { + CanCharge = false, + CanRun = true, + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + // dist - dto = 10 <= 15 → walk + p.GetCommand(dist: 10.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.None, holdKey); + } + + [Fact] + public void HoldKey_CanRunAndCanWalk_BeyondThreshold_Run() + { + var p = new MovementParameters + { + CanCharge = false, + CanRun = true, + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + // dist - dto = 15.1 > 15 → run + p.GetCommand(dist: 15.7f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.Run, holdKey); + } + + [Fact] + public void HoldKey_ThresholdEdge_ExactlyAtThreshold_IsInclusive_Walk() + { + // retail: (dist - distance_to_object) <= walk_run_threshhold → WALK. + // The raw's `test ah,0x41` after `fcom` renders as an inclusive + // "not greater than" (≤) — the boundary itself walks, not runs. + var p = new MovementParameters + { + CanCharge = false, + CanRun = true, + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + // dist - dto = exactly 15.0 + p.GetCommand(dist: 15.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.None, holdKey); + } + + [Fact] + public void HoldKey_ThresholdEdge_JustOverThreshold_Run() + { + var p = new MovementParameters + { + CanCharge = false, + CanRun = true, + CanWalk = true, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + // dist - dto = 15.0 + epsilon + p.GetCommand(dist: 15.600001f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.Run, holdKey); + } + + [Fact] + public void HoldKey_CanChargeSet_OverridesWalkIncapableAndThreshold() + { + // CanCharge fast-path wins even when every other flag would say walk. + var p = new MovementParameters + { + CanCharge = true, + CanRun = true, + CanWalk = true, + WalkRunThreshhold = 1000f, // would otherwise force walk + DistanceToObject = 0.6f, + }; + + p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(HoldKey.Run, holdKey); + } + + // ── the four capability quadrants × plain-towards distance bands ────── + + [Theory] + // (canRun, canWalk, canCharge, distBeyondThreshold) → expected HoldKey + [InlineData(true, true, false, false, HoldKey.None)] // both capable, close → walk + [InlineData(true, true, false, true, HoldKey.Run)] // both capable, far → run + [InlineData(true, false, false, false, HoldKey.Run)] // run-only, close → still run (no walk branch) + [InlineData(true, false, false, true, HoldKey.Run)] // run-only, far → run + [InlineData(false, true, false, false, HoldKey.None)] // walk-only → always walk + [InlineData(false, true, false, true, HoldKey.None)] // walk-only, far → still walk + [InlineData(false, false, false, false, HoldKey.None)] // neither capable, no charge → walk (falls through) + [InlineData(false, false, true, false, HoldKey.Run)] // can_charge alone → run regardless + public void HoldKey_FourCapabilityQuadrants_MatchRetailCascade( + bool canRun, bool canWalk, bool canCharge, bool distBeyondThreshold, HoldKey expected) + { + var p = new MovementParameters + { + CanRun = canRun, + CanWalk = canWalk, + CanCharge = canCharge, + WalkRunThreshhold = 15f, + DistanceToObject = 0.6f, + }; + + float dist = distBeyondThreshold ? 20f : 5f; // 20-0.6=19.4>15 ; 5-0.6=4.4<=15 + p.GetCommand(dist, headingDiff: 0f, out _, out HoldKey holdKey, out _); + + Assert.Equal(expected, holdKey); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs new file mode 100644 index 00000000..7931a5f3 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs @@ -0,0 +1,67 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — MovementParameters::get_desired_heading (0x0052aad0), +/// PINNED by direct Ghidra decompile of patchmem.gpr (see +/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2 — fetched +/// live during V0, ACE-shaped constants CONFIRMED exact): +/// +/// forward|run + towards → 0 forward|run + away → 180 +/// backward + towards → 180 backward + away → 0 +/// any other command → 0 +/// +/// +public sealed class MovementParametersGetDesiredHeadingTests +{ + [Theory] + [InlineData(false, 0f)] // RunForward, towards → 0 + [InlineData(true, 180f)] // RunForward, away → 180 + public void RunForward_FourQuadrant(bool movingAway, float expected) + { + var p = new MovementParameters(); + float h = p.GetDesiredHeading(MotionCommand.RunForward, movingAway); + Assert.Equal(expected, h); + } + + [Theory] + [InlineData(false, 0f)] // WalkForward, towards → 0 + [InlineData(true, 180f)] // WalkForward, away → 180 + public void WalkForward_FourQuadrant(bool movingAway, float expected) + { + var p = new MovementParameters(); + float h = p.GetDesiredHeading(MotionCommand.WalkForward, movingAway); + Assert.Equal(expected, h); + } + + [Theory] + [InlineData(false, 180f)] // WalkBackward, towards → 180 (face the target while backing up) + [InlineData(true, 0f)] // WalkBackward, away → 0 + public void WalkBackward_FourQuadrant(bool movingAway, float expected) + { + var p = new MovementParameters(); + float h = p.GetDesiredHeading(MotionCommand.WalkBackward, movingAway); + Assert.Equal(expected, h); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnknownCommand_DefaultsToZero(bool movingAway) + { + var p = new MovementParameters(); + float h = p.GetDesiredHeading(MotionCommand.TurnRight, movingAway); + Assert.Equal(0f, h); + } + + [Fact] + public void ZeroCommand_DefaultsToZero() + { + var p = new MovementParameters(); + Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: false)); + Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: true)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs new file mode 100644 index 00000000..25d942b4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs @@ -0,0 +1,84 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — MovementParameters::towards_and_away (0x0052a9a0, +/// raw 307917-307942), verbatim per r4-moveto-decomp.md §5d. Three bands: +/// beyond distance_to_object → WalkForward towards; inside the +/// min_distance epsilon band → WalkBackwards away (no turn, unlike +/// the pure-away branch in §5c which uses WalkForward+turn-around); strictly +/// between → idle (cmd 0). +/// +public sealed class MovementParametersTowardsAndAwayTests +{ + [Fact] + public void DistGreaterThanDto_WalkForward_Towards() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + p.TowardsAndAway(dist: 5f, out uint cmd, out bool movingAway); + + Assert.Equal(MotionCommand.WalkForward, cmd); + Assert.False(movingAway); + } + + [Fact] + public void DistExactlyAtDto_NotGreater_FallsToMinBandCheck() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + // dist == dto is NOT > dto, so falls through to the min-band test; + // 0.6 - 0.2 = 0.4, not < epsilon → idle. + p.TowardsAndAway(dist: 0.6f, out uint cmd, out _); + + Assert.Equal(0u, cmd); + } + + [Fact] + public void InsideMinDistanceEpsilonBand_WalkBackwards_Away() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + // dist - min_distance < 0.000199999995f + p.TowardsAndAway(dist: 0.2f, out uint cmd, out bool movingAway); + + Assert.Equal(MotionCommand.WalkBackward, cmd); + Assert.True(movingAway); + } + + [Fact] + public void InsideMinDistanceEpsilonBand_JustBelowEpsilon_StillWalkBackwards() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + p.TowardsAndAway(dist: 0.2f + 0.0001f, out uint cmd, out bool movingAway); + + Assert.Equal(MotionCommand.WalkBackward, cmd); + Assert.True(movingAway); + } + + [Fact] + public void StrictlyBetweenMinAndDto_Idle() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + p.TowardsAndAway(dist: 0.4f, out uint cmd, out bool movingAway); + + Assert.Equal(0u, cmd); + Assert.False(movingAway); + } + + [Fact] + public void JustOutsideMinBand_NotYetIdle_Idle() + { + var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; + + // dist - min = 0.0003, just over epsilon (0.0002) → NOT in the min band → idle + p.TowardsAndAway(dist: 0.2003f, out uint cmd, out _); + + Assert.Equal(0u, cmd); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs new file mode 100644 index 00000000..bc9e9b87 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs @@ -0,0 +1,108 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — widening per r4-moveto-decomp.md §0 +/// (acclient.h:38069, struct #4067): +/// +/// struct __cppobj MovementStruct +/// { +/// MovementTypes::Type type; +/// unsigned int motion; // types 1-4 only +/// unsigned int object_id; // types 6, 8 +/// unsigned int top_level_id; // types 6, 8 +/// Position pos; // type 7 +/// float radius; // type 6 +/// float height; // type 6 +/// MovementParameters *params; // types 1-4, 6-9 +/// }; +/// +/// Additive-only (M11, mechanical) — no consumer wires these fields yet; +/// this test just pins the shape exists and round-trips. +/// +public sealed class MovementStructWideningTests +{ + [Fact] + public void ObjectId_TopLevelId_RoundTrip() + { + var mvs = new MovementStruct + { + Type = MovementType.MoveToObject, + ObjectId = 0x50001234u, + TopLevelId = 0x50005678u, + }; + + Assert.Equal(0x50001234u, mvs.ObjectId); + Assert.Equal(0x50005678u, mvs.TopLevelId); + } + + [Fact] + public void Pos_RoundTrips_WorldPositionAndCell() + { + var pos = new Position(0x12340001u, new Vector3(10f, 20f, 3f), Quaternion.Identity); + var mvs = new MovementStruct + { + Type = MovementType.MoveToPosition, + Pos = pos, + }; + + Assert.Equal(pos, mvs.Pos); + Assert.Equal(0x12340001u, mvs.Pos.ObjCellId); + Assert.Equal(new Vector3(10f, 20f, 3f), mvs.Pos.Frame.Origin); + } + + [Fact] + public void Radius_Height_RoundTrip() + { + var mvs = new MovementStruct + { + Type = MovementType.MoveToObject, + Radius = 0.75f, + Height = 1.8f, + }; + + Assert.Equal(0.75f, mvs.Radius); + Assert.Equal(1.8f, mvs.Height); + } + + [Fact] + public void Params_HoldsMovementParametersReference() + { + var p = new MovementParameters { CanCharge = true }; + var mvs = new MovementStruct + { + Type = MovementType.TurnToHeading, + Params = p, + }; + + Assert.Same(p, mvs.Params); + } + + [Fact] + public void ExistingFields_Type_Motion_StillPresent_NoRegression() + { + // The pre-R4 fields (Type/Motion/Speed/Autonomous/ModifyInterpretedState/ + // ModifyRawState) must survive the widening untouched — R4 is + // additive-only per the plan (M11, "no consumer changes"). + var mvs = new MovementStruct + { + Type = MovementType.RawCommand, + Motion = 0x45000005u, + Speed = 1.5f, + Autonomous = true, + ModifyInterpretedState = true, + ModifyRawState = false, + }; + + Assert.Equal(MovementType.RawCommand, mvs.Type); + Assert.Equal(0x45000005u, mvs.Motion); + Assert.Equal(1.5f, mvs.Speed); + Assert.True(mvs.Autonomous); + Assert.True(mvs.ModifyInterpretedState); + Assert.False(mvs.ModifyRawState); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs new file mode 100644 index 00000000..ce6f8f05 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs @@ -0,0 +1,33 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V1 — widening to retail's full +/// MovementTypes::Type enum (acclient.h:2856, enum #229): +/// +/// Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3, +/// StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6, +/// MoveToPosition=7, TurnToObject=8, TurnToHeading=9 +/// +/// Mechanical, additive-only pin (M11) — the 1-5 values must not shift +/// (they're already load-bearing in MotionInterpreter.PerformMovement's +/// switch). +/// +public sealed class MovementTypeWideningTests +{ + [Theory] + [InlineData(MovementType.Invalid, 0)] + [InlineData(MovementType.RawCommand, 1)] + [InlineData(MovementType.InterpretedCommand, 2)] + [InlineData(MovementType.StopRawCommand, 3)] + [InlineData(MovementType.StopInterpretedCommand, 4)] + [InlineData(MovementType.StopCompletely, 5)] + [InlineData(MovementType.MoveToObject, 6)] + [InlineData(MovementType.MoveToPosition, 7)] + [InlineData(MovementType.TurnToObject, 8)] + [InlineData(MovementType.TurnToHeading, 9)] + public void EnumValues_MatchRetailMovementTypesTypeTable(MovementType value, int expected) + => Assert.Equal(expected, (int)value); +} diff --git a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs index 383e8b34..e42b23cc 100644 --- a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs +++ b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs @@ -23,6 +23,15 @@ public sealed class WeenieErrorCodeTableTests public void NoPhysicsObject_Is0x08() => Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject); + /// + /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per + /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants + /// inventory row (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). + /// + [Fact] + public void NoMotionInterpreter_Is0x0B() + => Assert.Equal(0x0Bu, (uint)WeenieError.NoMotionInterpreter); + [Fact] public void NotGrounded_Is0x24() => Assert.Equal(0x24u, (uint)WeenieError.NotGrounded); @@ -43,6 +52,38 @@ public sealed class WeenieErrorCodeTableTests public void ChatEmoteOutsideNonCombat_Is0x42() => Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat); + /// + /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site: + /// MoveToManager::PerformMovement (§3a @0052a901) — every new + /// moveto cancels the previous one with this code before dispatching; + /// also CPhysicsObj::interrupt_current_movement's + /// MovementManager::CancelMoveTo(0x36) call (§9e). Per §7c, the + /// arg is NEVER READ inside MoveToManager::CancelMoveTo's body in + /// this build — kept for parity/logging, not behavior. + /// + [Fact] + public void ActionCancelled_Is0x36() + => Assert.Equal(0x36u, (uint)WeenieError.ActionCancelled); + + /// + /// 0x37 — ObjectGone. R4-V1 addition (M12). Site: + /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — + /// retarget delivery with a non-OK target status. + /// + [Fact] + public void ObjectGone_Is0x37() + => Assert.Equal(0x37u, (uint)WeenieError.ObjectGone); + + /// + /// 0x38 — NoObject. R4-V1 addition (M12). Site: + /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the + /// FIRST target callback arrives with a non-OK status (target never + /// resolved). + /// + [Fact] + public void NoObject_Is0x38() + => Assert.Equal(0x38u, (uint)WeenieError.NoObject); + [Fact] public void ActionDepthExceeded_Is0x45() => Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded); @@ -59,6 +100,15 @@ public sealed class WeenieErrorCodeTableTests public void CantJumpLoadedDown_Is0x49() => Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown); + /// + /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site: + /// MoveToManager::HandleMoveToPosition Phase 2 arrival check — + /// fail_distance exceeded (r4-moveto-decomp.md §6b). + /// + [Fact] + public void YouChargedTooFar_Is0x3D() + => Assert.Equal(0x3Du, (uint)WeenieError.YouChargedTooFar); + /// /// Every code in the A10 table in one pass — guards against a /// future partial edit desyncing an individual test above from the @@ -67,7 +117,11 @@ public sealed class WeenieErrorCodeTableTests [Theory] [InlineData(WeenieError.None, 0x00u)] [InlineData(WeenieError.NoPhysicsObject, 0x08u)] + [InlineData(WeenieError.NoMotionInterpreter, 0x0Bu)] [InlineData(WeenieError.NotGrounded, 0x24u)] + [InlineData(WeenieError.ActionCancelled, 0x36u)] + [InlineData(WeenieError.ObjectGone, 0x37u)] + [InlineData(WeenieError.NoObject, 0x38u)] [InlineData(WeenieError.CrouchInCombatStance, 0x3fu)] [InlineData(WeenieError.SitInCombatStance, 0x40u)] [InlineData(WeenieError.SleepInCombatStance, 0x41u)] @@ -76,6 +130,7 @@ public sealed class WeenieErrorCodeTableTests [InlineData(WeenieError.GeneralMovementFailure, 0x47u)] [InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)] [InlineData(WeenieError.CantJumpLoadedDown, 0x49u)] + [InlineData(WeenieError.YouChargedTooFar, 0x3Du)] public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected) => Assert.Equal(expected, (uint)code); }