acdream/src/AcDream.Core/Physics/LandDefs.cs
Erik 438bb681a5 feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630)
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:30:20 +02:00

175 lines
7.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>LandDefs</c> outdoor-cell coordinate math (issue #106). All functions
/// operate on the GLOBAL landcell coordinate space (<c>lcoord</c>, one unit per 24 m
/// cell, range [0, 0x7F8) across the whole map) — landblock crossings are inherent
/// in the math, never special-cased. Ported per
/// <c>docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md</c>; cross-checked
/// against ACE <c>Physics/Common/LandDefs.cs</c>.
///
/// <para>Frame note: retail positions are landblock-local ([0, 192) per axis,
/// relative to the cell id's block). acdream physics positions are in the floating
/// world frame (anchor landblock at origin) — callers convert via the current
/// block's registered world origin BEFORE calling in here.</para>
/// </summary>
public static class LandDefs
{
/// <summary>24 m landcell side (retail <c>square_length</c>).</summary>
public const float CellLength = 24f;
/// <summary>192 m landblock side.</summary>
public const float BlockLength = 192f;
/// <summary>Map-wide lcoord bound: 255 blocks × 8 cells (retail <c>0x7F8</c>).</summary>
public const int LandLength = 0x7F8;
// Retail PhysicsGlobals::EPSILON as used by adjust_to_outside's coordinate
// snap (decomp :438719 shows the literal 0.000199999995f).
private const float Epsilon = 0.000199999995f;
/// <summary>
/// <c>LandDefs::in_bounds</c> (pc:68509, @0x0043d650): both lcoord axes inside
/// the map.
/// </summary>
public static bool InBounds(int lx, int ly)
=> lx >= 0 && ly >= 0 && lx < LandLength && ly < LandLength;
/// <summary>
/// <c>LandDefs::blockid_to_lcoord</c> (pc:68520, @0x0043d680): the lcoord of a
/// landblock's (0,0) cell. Block bytes are ZERO-extended — the decomp's
/// <c>int8_t</c> cast on block_y is a Binary Ninja mis-render (ACE
/// LandDefs.cs:169 confirms plain masks).
/// </summary>
public static bool BlockIdToLcoord(uint cellId, out int lx, out int ly)
{
if (cellId == 0u) { lx = 0; ly = 0; return false; }
lx = (int)((cellId >> 24) & 0xFFu) << 3;
ly = (int)((cellId >> 16) & 0xFFu) << 3;
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::inbound_valid_cellid</c> (pc:163438, @0x004979a0): low word in
/// a valid range (landcell 1..0x40, envcell 0x100..0xFFFD, or the 0xFFFF block
/// sentinel) and the block lcoord inside the map. Retail checks BOTH axes (ACE
/// checks only X — an ACE divergence, not copied).
/// </summary>
public static bool InboundValidCellId(uint cellId)
{
if (!CellLowInRange(cellId & 0xFFFFu)) return false;
int lx = (int)((cellId >> 24) & 0xFFu) << 3;
int ly = (int)((cellId >> 16) & 0xFFu) << 3;
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::gid_to_lcoord</c> (pc:163500, @0x00497a90): a full OUTDOOR cell
/// id to its global lcoord. Fails for indoor ids (low ≥ 0x100).
/// </summary>
public static bool GidToLcoord(uint cellId, out int lx, out int ly)
{
lx = 0; ly = 0;
if (!InboundValidCellId(cellId)) return false;
uint low = cellId & 0xFFFFu;
if (low >= 0x100u) return false; // outdoor only
lx = ((int)((cellId >> 24) & 0xFFu) << 3) + (int)((low - 1u) >> 3);
ly = ((int)((cellId >> 16) & 0xFFu) << 3) + (int)((low - 1u) & 7u);
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::lcoord_to_gid</c> (pc:171859, @0x004a19a0): a global lcoord to
/// the full outdoor cell id — the block id is RE-DERIVED from the lcoord's
/// upper bits, so a neighbour-block lcoord yields the neighbour's cell id.
/// Returns 0 when out of map bounds (retail behavior).
/// </summary>
public static uint LcoordToGid(int lx, int ly)
{
if (!InBounds(lx, ly)) return 0u;
uint low = (uint)((ly & 7) + ((lx & 7) << 3) + 1);
uint block = (uint)(((lx >> 3) << 8) | (ly >> 3));
return (block << 16) | low;
}
/// <summary>
/// <c>LandDefs::get_outside_lcoord</c> (pc:438690, @0x005a9b00): global lcoord
/// of the landcell under <paramref name="blockLocalPos"/>, expressed relative to
/// <paramref name="cellId"/>'s block. <c>floor(pos/24)</c> may be negative or
/// ≥ 8 — that IS the landblock crossing; the only rejection is the map edge.
/// </summary>
public static bool GetOutsideLcoord(uint cellId, Vector3 blockLocalPos, out int lx, out int ly)
{
lx = 0; ly = 0;
if (!CellLowInRange(cellId & 0xFFFFu)) return false;
BlockIdToLcoord(cellId, out lx, out ly);
lx += (int)MathF.Floor(blockLocalPos.X / CellLength);
ly += (int)MathF.Floor(blockLocalPos.Y / CellLength);
return InBounds(lx, ly);
}
/// <summary>
/// <c>LandDefs::adjust_to_outside</c> (pc:438719, @0x005a9bc0): re-seat
/// (<paramref name="cellId"/>, <paramref name="blockLocalPos"/>) onto the outdoor
/// landcell actually under the point. On success the cell id may belong to a
/// NEIGHBOUR landblock and the position is re-based into that block's local
/// frame (<c>pos -= floor(pos/192)·192</c>; the decomp's <c>0f</c> divisor is a
/// BN artifact — ACE LandDefs.cs:140 confirms BlockLength). On failure the cell
/// id is zeroed (retail behavior).
/// </summary>
public static bool AdjustToOutside(ref uint cellId, ref Vector3 blockLocalPos)
{
if (CellLowInRange(cellId & 0xFFFFu))
{
if (MathF.Abs(blockLocalPos.X) < Epsilon) blockLocalPos.X = 0f;
if (MathF.Abs(blockLocalPos.Y) < Epsilon) blockLocalPos.Y = 0f;
if (GetOutsideLcoord(cellId, blockLocalPos, out int lx, out int ly))
{
cellId = LcoordToGid(lx, ly);
blockLocalPos.X -= MathF.Floor(blockLocalPos.X / BlockLength) * BlockLength;
blockLocalPos.Y -= MathF.Floor(blockLocalPos.Y / BlockLength) * BlockLength;
return true;
}
}
cellId = 0u;
return false;
}
/// <summary>
/// <c>LandDefs::get_block_offset</c> (pc:69189, @0x0043e630): world-meter offset
/// from <paramref name="source"/>'s landblock origin to <paramref name="dest"/>'s.
/// ZeroVector when both ids share a landblock (high words equal). The decomp's
/// <c>block_byte&lt;&lt;3</c> converts to lcoord (cell) units and the literal
/// <c>·24 m/cell</c> then nets to <c>(Δlandblock)·192 m</c> per axis. The
/// degenerate <c>arg==0</c> fallbacks are ported verbatim (dest==0 → raw source
/// id; source==0 → 0 lcoord). The ONLY cross-cell translation in retail physics:
/// a delta of two NAMED cell ids, never an accumulation against a moving center.
/// </summary>
public static Vector3 GetBlockOffset(uint source, uint dest)
{
uint srcBlock = source >> 16;
uint dstBlock = dest >> 16;
if (srcBlock == dstBlock)
return Vector3.Zero;
int srcLx, srcLy;
if (source == 0u) { srcLx = 0; srcLy = 0; }
else { srcLx = (int)((source >> 21) & 0x7f8u); srcLy = (int)((srcBlock & 0xFFu) << 3); }
int dstLx, dstLy;
if (dest == 0u) { dstLx = (int)source; dstLy = (int)source; } // retail degenerate guard
else { dstLx = (int)((dest >> 21) & 0x7f8u); dstLy = (int)((dstBlock & 0xFFu) << 3); }
return new Vector3((dstLx - srcLx) * CellLength, (dstLy - srcLy) * CellLength, 0f);
}
// Retail cell_in_range: landcell, envcell, or the block sentinel.
private static bool CellLowInRange(uint low)
=> low is (>= 1u and <= 0x40u) or (>= 0x100u and <= 0xFFFDu) or 0xFFFFu;
}