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. This method uses the scalar bridge derived from acdream's /// 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); } /// /// R4-V5: scalar projection of . The local /// player's Yaw property exposes this horizontal projection while /// its complete body quaternion remains authoritative. Same P5 bridge: /// heading = (90 - yawDeg) mod 360. /// public static float HeadingFromYaw(float yawRad) { float headingDeg = 90f - yawRad * (180f / MathF.PI); headingDeg %= 360f; if (headingDeg < 0f) headingDeg += 360f; return headingDeg; } /// /// R4-V5: exact inverse of — the scalar /// bridge used by an explicit Frame::set_heading operation. This /// operation deliberately replaces pitch/roll; ordinary frame composition /// does not pass through this projection. /// Returns radians wrapped to [-π, π] matching the controller's own /// wrap discipline. /// public static float YawFromHeading(float headingDeg) { float yaw = (90f - headingDeg) * (MathF.PI / 180f); while (yaw > MathF.PI) yaw -= 2f * MathF.PI; while (yaw < -MathF.PI) yaw += 2f * MathF.PI; return yaw; } /// /// 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). Binary Ninja garbles the x87 result plumbing, but the complete /// branch structure is visible in /// Position::cylinder_distance @ 0x005A97F0 and is independently /// preserved by ACE Physics.Common.Position.CylinderDistance: /// subtract both radii from the full 3D center distance, compute the /// signed vertical gap between the two cylinders, then combine gaps only /// when their signs agree. Overlap is therefore a negative distance /// rather than a value clamped to zero. /// public static float CylinderDistance( float ownRadius, float ownHeight, Vector3 ownPos, float targetRadius, float targetHeight, Vector3 targetPos) { float radialGap = Vector3.Distance(ownPos, targetPos) - (ownRadius + targetRadius); float verticalGap = ownPos.Z <= targetPos.Z ? targetPos.Z - (ownPos.Z + ownHeight) : ownPos.Z - (targetPos.Z + targetHeight); if (verticalGap > 0f && radialGap > 0f) return MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap); if (verticalGap < 0f && radialGap < 0f) return -MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap); return radialGap; } /// /// Retail Position::cylinder_distance_no_z — the signed /// horizontal (X/Y) edge-to-edge distance between two cylinders: /// centerDist − ownRadius − targetRadius. Unlike /// (the arrival-gate variant, which CLAMPS at /// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE /// value. StickyManager::adjust_offset (0x00555430) relies on the /// sign: when the follower is inside the desired 0.3 m stick gap the /// distance goes negative, the per-tick delta inverts, and the mover backs /// off to restore the gap (ACE StickyManager.cs:156). /// public static float CylinderDistanceNoZ( float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos) { float dx = targetPos.X - ownPos.X; float dy = targetPos.Y - ownPos.Y; float centerDist = MathF.Sqrt(dx * dx + dy * dy); return centerDist - ownRadius - targetRadius; } /// /// Retail AC1Legacy::Vector3::normalize_check_small — normalize /// in place, returning true if the vector was /// too small to normalize (near-zero) and leaving it unchanged in that /// case. Consumed by StickyManager::adjust_offset (don't chase /// jitter when already at the target) and /// (interpolated-heading /// fallback). A public shared twin of the private helper in /// ParticleSystem; same 1e-8 near-zero length guard. /// /// true = too small (left unchanged); false = /// normalized. public static bool NormalizeCheckSmall(ref Vector3 v) { float length = v.Length(); if (length < 1e-8f) return true; v /= length; return false; } /// /// Retail Position::globaltolocalvec — rotate a WORLD-space vector /// into a frame's LOCAL coordinates by the inverse of the frame's /// orientation. Consumed by StickyManager::adjust_offset /// (0x00555430) to express the self→target offset in the mover's own frame /// before flattening Z and steering. Pure rotation (no translation) — /// is a direction/offset, not a point. /// public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec) => Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation)); /// /// Landblock-local wire origin → world space (verbatim relocation from /// the deleted RemoteMoveToDriver.OriginToWorld, R4-V4): MoveTo / /// TurnTo packets carry positions block-local; acdream's streaming world /// re-centers on a live-center landblock grid. /// public static Vector3 OriginToWorld( uint originCellId, float originX, float originY, float originZ, int liveCenterLandblockX, int liveCenterLandblockY) { int lbX = (int)((originCellId >> 24) & 0xFFu); int lbY = (int)((originCellId >> 16) & 0xFFu); return new Vector3( originX + (lbX - liveCenterLandblockX) * 192f, originY + (lbY - liveCenterLandblockY) * 192f, originZ); } }