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);
}
}