acdream/tests/AcDream.Core.Tests/Physics/LandDefsTests.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

200 lines
7.4 KiB
C#
Raw Permalink 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.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for the retail LandDefs global-lcoord math (issue #106).
/// Goldens derive from the decomp formulas (pseudocode doc:
/// docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md) using the
/// Holtburg geometry from the #106 capture: landblock 0xA9B4 with its southern
/// neighbour 0xA9B3 (block_x 0xA9 = 169, block_y 0xB4 = 180 / 0xB3 = 179).
/// </summary>
public class LandDefsTests
{
// ── blockid_to_lcoord (pc:68520) ────────────────────────────────────
[Fact]
public void BlockIdToLcoord_A9B4_Is_1352_1440()
{
Assert.True(LandDefs.BlockIdToLcoord(0xA9B40031u, out int lx, out int ly));
Assert.Equal(169 * 8, lx);
Assert.Equal(180 * 8, ly);
}
[Fact]
public void BlockIdToLcoord_HighBlockY_NoSignExtension()
{
// BN decomp renders block_y extraction with an int8_t cast that would
// sign-extend 0xB4 → negative. ACE confirms zero-extension; this test
// pins it for every block byte ≥ 0x80.
Assert.True(LandDefs.BlockIdToLcoord(0xFEFE0001u, out int lx, out int ly));
Assert.Equal(0xFE * 8, lx);
Assert.Equal(0xFE * 8, ly);
}
[Fact]
public void BlockIdToLcoord_ZeroCellId_Fails()
{
Assert.False(LandDefs.BlockIdToLcoord(0u, out _, out _));
}
// ── gid_to_lcoord (pc:163500) ───────────────────────────────────────
[Fact]
public void GidToLcoord_A9B40031_Is_1358_1440()
{
// low 0x31 = 49 → cell_x = (49-1)>>3 = 6, cell_y = (49-1)&7 = 0.
Assert.True(LandDefs.GidToLcoord(0xA9B40031u, out int lx, out int ly));
Assert.Equal(1358, lx);
Assert.Equal(1440, ly);
}
[Fact]
public void GidToLcoord_IndoorCell_Fails()
{
// Outdoor-only (decomp gates on low < 0x100).
Assert.False(LandDefs.GidToLcoord(0xA9B40164u, out _, out _));
}
[Fact]
public void GidToLcoord_InvalidLowRange_Fails()
{
Assert.False(LandDefs.GidToLcoord(0xA9B40000u, out _, out _)); // low 0
Assert.False(LandDefs.GidToLcoord(0xA9B40041u, out _, out _)); // low 0x41 (> 0x40, < 0x100)
}
// ── lcoord_to_gid (pc:171859) ───────────────────────────────────────
[Fact]
public void LcoordToGid_RoundTrips_A9B40031()
{
Assert.Equal(0xA9B40031u, LandDefs.LcoordToGid(1358, 1440));
}
[Fact]
public void LcoordToGid_CrossesBlockSouth()
{
// One lcoord row south of A9B4's southern edge → block_y 179 (0xB3),
// cell row 7: low = (1439&7) + (1358&7)*8 + 1 = 7 + 48 + 1 = 0x38.
Assert.Equal(0xA9B30038u, LandDefs.LcoordToGid(1358, 1439));
}
[Fact]
public void LcoordToGid_OutOfMapBounds_ReturnsZero()
{
Assert.Equal(0u, LandDefs.LcoordToGid(-1, 5));
Assert.Equal(0u, LandDefs.LcoordToGid(5, -1));
Assert.Equal(0u, LandDefs.LcoordToGid(0x7F8, 5));
Assert.Equal(0u, LandDefs.LcoordToGid(5, 0x7F8));
}
// ── get_outside_lcoord (pc:438690) ──────────────────────────────────
[Fact]
public void GetOutsideLcoord_NegativeLocalY_CrossesSouth()
{
// floor(-1/24) = -1 (floor, not truncation — negative-safe).
Assert.True(LandDefs.GetOutsideLcoord(
0xA9B40031u, new Vector3(150f, -1f, 0f), out int lx, out int ly));
Assert.Equal(1358, lx);
Assert.Equal(1439, ly);
}
[Fact]
public void GetOutsideLcoord_FromIndoorCellId_UsesBlockBits()
{
// adjust_to_outside accepts indoor low16 (0x100..0xFFFD) — the block
// bits drive the lcoord; the position picks the landcell.
Assert.True(LandDefs.GetOutsideLcoord(
0xA9B40164u, new Vector3(12f, 12f, 0f), out int lx, out int ly));
Assert.Equal(1352, lx);
Assert.Equal(1440, ly);
}
// ── adjust_to_outside (pc:438719) ───────────────────────────────────
[Fact]
public void AdjustToOutside_SouthCrossing_RewritesCellAndRebasesPos()
{
uint cellId = 0xA9B40031u;
var pos = new Vector3(150f, -1f, 5f);
Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0xA9B30038u, cellId);
Assert.Equal(150f, pos.X, 4);
Assert.Equal(191f, pos.Y, 4); // -1 floor(-1/192)·192 = 191 (new block's frame)
Assert.Equal(5f, pos.Z, 4); // Z untouched
}
[Fact]
public void AdjustToOutside_DeepSouth_CaptureGolden()
{
// The #106 capture geometry: player ~109.65 m south of A9B4's origin
// (≈82 m into A9B3). floor(-109.65/24) = -5 → ly 1435 → row 3 of 0xB3.
uint cellId = 0xA9B40031u;
var pos = new Vector3(150f, -109.65f, 0f);
Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0xA9B30034u, cellId);
Assert.Equal(82.35f, pos.Y, 3);
}
[Fact]
public void AdjustToOutside_NorthboundReturn()
{
// From A9B3's frame, local y = 193 is 1 m into A9B4.
uint cellId = 0xA9B30038u;
var pos = new Vector3(150f, 193f, 0f);
Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0xA9B40031u, cellId);
Assert.Equal(1f, pos.Y, 4);
}
[Fact]
public void AdjustToOutside_WithinBlock_KeepsBlockRewritesCell()
{
uint cellId = 0xA9B40031u; // cell (6,0)
var pos = new Vector3(12f, 12f, 0f); // over cell (0,0)
Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0xA9B40001u, cellId);
Assert.Equal(12f, pos.X, 4);
Assert.Equal(12f, pos.Y, 4);
}
[Fact]
public void AdjustToOutside_EpsilonSnap_TreatsTinyNegativeAsZero()
{
// Retail snaps |coord| < 0.0002 to 0 BEFORE the floor — a hair-negative
// y stays in the current block instead of flapping to the neighbour.
uint cellId = 0xA9B40031u;
var pos = new Vector3(150f, -0.0001f, 0f);
Assert.True(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0xA9B40031u, cellId);
Assert.Equal(0f, pos.Y, 6);
}
[Fact]
public void AdjustToOutside_InvalidLow_FailsAndZeroesCell()
{
uint cellId = 0xA9B40050u; // low 0x50: not landcell, not envcell
var pos = new Vector3(12f, 12f, 0f);
Assert.False(LandDefs.AdjustToOutside(ref cellId, ref pos));
Assert.Equal(0u, cellId); // retail writes 0 on failure
}
// ── inbound_valid_cellid (pc:163438) ────────────────────────────────
[Theory]
[InlineData(0xA9B40001u, true)]
[InlineData(0xA9B40040u, true)]
[InlineData(0xA9B40100u, true)]
[InlineData(0xA9B4FFFDu, true)]
[InlineData(0xA9B4FFFFu, true)] // block sentinel
[InlineData(0xA9B40000u, false)]
[InlineData(0xA9B40041u, false)]
[InlineData(0xA9B4FFFEu, false)]
public void InboundValidCellId_LowRanges(uint cellId, bool expected)
{
Assert.Equal(expected, LandDefs.InboundValidCellId(cellId));
}
}