acdream/src/AcDream.Core/Physics/LandDefs.cs
Erik 7078264291 fix(phys): #106 — outdoor membership crosses landblock boundaries (LandDefs global-lcoord port)
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>
2026-06-09 23:10:59 +02:00

147 lines
6.3 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;
}
// 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;
}