The player's outdoor cell froze at the last in-block cell the moment they
walked over a landblock boundary (10,449-frame playerCell freeze in the
2026-06-09 capture; whole neighbouring-block interiors unenterable, plus
the running-distortion from the stale render anchor). Root cause: the
add_all_outside_cells port clamped BOTH the candidate proposal and the
find_cell_list containing-cell pick to the current landblock's 8x8 grid,
in a frame that silently assumed the current block sits at world origin.
One step over the line -> zero candidates -> FindCellSet returns
currentCellId forever.
Retail has no such clamp. Its cell math runs in a GLOBAL landcell grid
(lcoord 0..2039 spanning the map): get_outside_lcoord = blockid_to_lcoord
+ floor(blockLocalPos/24) with no bounds besides the map edge, and
lcoord_to_gid re-derives the landblock id from the lcoord's upper bits —
crossings are inherent, never special-cased.
The fix, decomp-cited throughout:
- New AcDream.Core.Physics.LandDefs: in_bounds (pc:68509),
blockid_to_lcoord (pc:68520), inbound_valid_cellid (pc:163438),
gid_to_lcoord (pc:163500), lcoord_to_gid (pc:171859),
get_outside_lcoord (pc:438690), adjust_to_outside (pc:438719).
Cross-checked against ACE LandDefs.cs; three artifacts documented and
avoided: BN's int8_t mis-render of block_y, BN's dropped 192f
BlockLength constant, and ACE add_cell_block's "FIXME!" same-block
guard (an ACE divergence, not retail).
- CellTransit.AddAllOutsideCells rewritten as the faithful sphere
variant (pc:317499 @0x00533630): adjust_to_outside re-seats the
(cell, position) pair cross-block, check_add_cell_boundary (pc:317229)
adds up to 3 neighbours by global lcoord, add_outside_cell (pc:317056)
has no same-block filter. adjust_to_outside failure breaks the sphere
loop (pc:533699 verbatim).
- BuildCellSetAndPickContaining: the outdoor containing-cell pick is now
the global XY-column under the sphere centre (AdjustToOutside), not
the [0,8)-clamped current-prefix reconstruction. Interior-wins order
and current-cell-first hysteresis unchanged.
- World->block-local frame conversion via the landblock origin already
registered in CellGraph (new TryGetTerrainOrigin); Zero fallback
preserves the legacy anchor-block assumption for unregistered terrain.
- Cross-landblock building entry comes free: the candidate snapshot now
contains neighbour-block landcells, so GetBuilding/CheckBuildingTransit
fire for cottages across the line (the capture's one failing entry).
Investigated FIRST per the pickup brief: the b3ce505 #98 stopgap gate is
definitively exonerated — it is a collision-object query gate that fires
only for indoor primary cells; no membership path touches
ShadowObjectRegistry.
Tests: 31 new (25 LandDefs conformance incl. capture-geometry goldens
0xA9B40031 -> 0xA9B30038/0xA9B30034 and the northbound return; 4
AddAllOutsideCells cross-block; 3 FindCellSet membership goldens incl.
the non-anchor-frame origin conversion). Full suite: 294+218+420 green;
Core 1369 green + the 4 pre-existing door/#99-era failures + 1 skip
(unchanged from baseline).
Pseudocode + artifact notes:
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
Remaining acceptance: live boundary walk with ACDREAM_PROBE_CELL=1
(ISSUES.md #106).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
6.3 KiB
C#
147 lines
6.3 KiB
C#
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;
|
||
}
|
||
|
||
// 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;
|
||
}
|