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>
This commit is contained in:
Erik 2026-06-09 23:10:59 +02:00
parent 12fb408972
commit 7078264291
11 changed files with 813 additions and 88 deletions

View file

@ -159,6 +159,75 @@ public class CellTransitFindCellSetTests
Assert.Contains(0xA9B40001u, cellSet);
}
// ──────────────────────────────────────────────────────────────────
// #106 — outdoor membership across landblock boundaries.
// Retail's add_all_outside_cells + the find_cell_list pick run in the
// GLOBAL landcell grid (LandDefs lcoords); crossing a landblock boundary
// is inherent. The pre-#106 port clamped both to the current block's 8×8
// grid → zero candidates one step over the line → membership frozen
// (the 10,449-frame playerCell freeze in flap-105-capture.log).
// ──────────────────────────────────────────────────────────────────
[Fact]
public void OutdoorSeed_CrossesLandblockBoundary_South()
{
// The #106 acceptance golden: walking south out of A9B4, the outdoor
// cell must advance to the southern neighbour block's cell. Anchor
// frame (no registered terrain → origin Zero): world y = -0.2 is
// 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038.
var cache = new PhysicsDataCache();
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u,
out var cellSet);
Assert.Equal(0xA9B30038u, containing);
Assert.Contains(0xA9B30038u, cellSet);
Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set
}
[Fact]
public void OutdoorSeed_NearBoundaryButInside_StaysCurrent()
{
// 0.2 m NORTH of the boundary: the candidate set includes the A9B3
// neighbour (sphere overlaps it) but the centre column is still the
// current cell — membership must NOT flip early (single clean flip
// at the line, matching the capture's 96/96 within-block behaviour).
var cache = new PhysicsDataCache();
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u,
out var cellSet);
Assert.Equal(0xA9B40031u, containing);
Assert.Contains(0xA9B30038u, cellSet);
}
[Fact]
public void OutdoorSeed_NonAnchorBlock_UsesRegisteredTerrainOrigin()
{
// Northbound return: the player's current cell is in A9B3 (origin
// (0, -192) registered via CellGraph terrain — the production path).
// World y = +1 is 1 m back into the anchor block A9B4; the pick must
// convert through A9B3's origin (block-local y = 193) and advance to
// 0xA9B40031. Pre-#106 the world frame was silently assumed
// block-local, which is wrong for every non-anchor block.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
0xA9B30000u,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(0f, -192f, 0f));
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B30038u,
out _);
Assert.Equal(0xA9B40031u, containing);
}
// ──────────────────────────────────────────────────────────────────
// Membership hysteresis — the R1-flap root cause.
// Retail CObjCell::find_cell_list adds the CURRENT cell at index 0