fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)

BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.

Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).

Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.

Divergence AD-30 added to retail-divergence-register.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 13:38:48 +02:00
parent aba882cec6
commit 02f4be72c0
7 changed files with 144 additions and 15 deletions

View file

@ -34,6 +34,12 @@ public sealed class Issue112MembershipTests
try { ConformanceDats.LoadEnvCell(dats, cache, lbPrefix | low); }
catch { }
}
// #145 D: register terrain for the block so TryGetTerrainOrigin succeeds for
// outdoor seeds (in production the streaming-center landblock is always resident
// before outdoor physics resolves run). Tests use block-local coordinates, so
// origin is (0,0,0). Without this, Component D's early-return for unregistered
// terrain would prevent the building-portal walk that promotes outdoor→indoor.
cache.CellGraph.RegisterTerrain(lbPrefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
return cache;
}

View file

@ -0,0 +1,85 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
// Component D — cell-march landblock preservation (#145 residual, 2026-06-22 spec §3 D).
//
// Scenario: a player arrives via teleport at outdoor cell 0xA9B40019 (lbX=0xA9, lbY=0xB4).
// The destination landblock has NOT yet been applied to the physics engine (priority-apply
// is in flight). The body's CarriedBlockOrigin is null (either the body has no seeded outdoor
// cell yet, or the incoming cell differs from the body's last-known cell).
//
// Without the fix: BuildCellSetAndPickContaining's TryGetTerrainOrigin returns false with
// blockOrigin=(0,0,0). AddAllOutsideCells treats worldSphereCenter as block-local and marches
// the cell one block in whatever direction the player is moving — lbX→lbX1 or lbY→lbY1
// per tick, cascading until lbX or lbY underflows to 0x00. The outbound wire then encodes
// the marched cell and ACE rejects every move (MOVEMENT SPEED / failed transition).
//
// With the fix: when TryGetTerrainOrigin returns false (block not resident), return the
// seed currentCellId verbatim — "no block-local frame → preserve" (same contract as
// PhysicsEngine.Resolve's NO-LANDBLOCK verbatim branch). The player cell stays correct
// until the landblock applies.
//
// The entry point exercised here is CellTransit.FindCellSet — the same function that
// RunCheckOtherCellsAndAdvance (TransitionTypes.cs) calls on every per-frame physics tick.
public class CellMarchLandblockPreservationTests
{
private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu);
private static int LbY(uint cellId) => (int)((cellId >> 16) & 0xFFu);
// ── West-edge crossing: no anchor, unstreamed terrain → must NOT march lbX ──────────────
[Fact]
public void WestEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell()
{
// Empty cache — landblock 0xA9B4 has not been applied (TryGetTerrainOrigin → false).
var cache = new PhysicsDataCache();
// Sphere just west of 0xA9B4's origin (world X = 0.088 → block-local X = 0.088
// relative to (0,0) origin → floor(0.088/24) = 1 → lbX marches without fix).
var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(-0.088f, 14.8f, 12f), Radius = 0.48f } };
const uint currentCell = 0xA9B40019u;
// No carried anchor (the teleport-arrival first tick: body still carries an old
// cell or is null-seeded, so ResolveWithTransition passes null).
uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
// The cell must be preserved unchanged — lbX must NOT drop below 0xA9.
Assert.Equal(0xA9, LbX(result));
// The full cell id must equal the seed (no march at all).
Assert.Equal(currentCell, result);
}
// ── South-edge crossing: no anchor, unstreamed terrain → must NOT march lbY ─────────────
[Fact]
public void SouthEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell()
{
var cache = new PhysicsDataCache();
var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } };
const uint currentCell = 0xA9B40001u; // south-west landcell of 0xA9B4
uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
// Without fix: lbY drops from 0xB4 to 0xB3 on the first tick, then cascades.
Assert.Equal(0xB4, LbY(result));
Assert.Equal(currentCell, result);
}
// ── Carried anchor STILL works: if the anchor is supplied, the correct neighbour ────────
// landblock cell is returned even when terrain is unstreamed (the original #145 fix).
[Fact]
public void WithCarriedAnchor_UnstreamedNeighbour_ReturnsCorrectCell()
{
var cache = new PhysicsDataCache();
// Player crossed the south edge into 0xA9B3 (origin (0, 192)).
var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } };
const uint currentCell = 0xA9B30001u;
var anchor = new Vector3(0f, -192f, 0f); // body.Position CellPosition.Origin
uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor);
// The anchor gives the correct block-local frame → stays in 0xA9B3 (no march back to B4).
Assert.Equal(0xB3, LbY(result));
}
}

View file

@ -110,6 +110,9 @@ public class CellTransitFindCellSetTests
public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells()
{
var cache = new PhysicsDataCache();
// #145 D: register A9B4 at origin (0,0) — in production the streaming-center
// landblock is always registered before any outdoor physics resolve runs.
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
// A6.P4 (2026-05-24): sphere coords are LANDBLOCK-LOCAL (X/Y in
// [0, 192]). Place the sphere center near the east boundary of
// landcell grid(0,0) (i.e., near local X=24) so AddAllOutsideCells
@ -172,10 +175,13 @@ public class CellTransitFindCellSetTests
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.
// cell must advance to the southern neighbour block's cell. Origin of
// A9B4 is Zero (streaming center). #145 D: register A9B4 at (0,0) so
// TryGetTerrainOrigin succeeds (in production the streaming-center
// landblock is always resident before any outdoor physics resolve).
// world y = -0.2 is 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
@ -194,7 +200,9 @@ public class CellTransitFindCellSetTests
// 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).
// #145 D: register A9B4 at (0,0) — streaming-center block is always resident.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f,

View file

@ -459,7 +459,13 @@ public class PhysicsEngineTests
// FindObjCollisions early-returns when DataCache is null. An empty
// cache is enough for cylinder objects; only BSP objects look up
// entries inside.
engine.DataCache = new PhysicsDataCache();
// #145 D: register terrain for 0xA9B4 at (0,0,0) so TryGetTerrainOrigin
// succeeds for the outdoor seed cell (0xA9B40039). In production the
// streaming-center landblock is always resident before outdoor resolves run;
// we replicate that invariant here by registering a flat dummy terrain.
var freshCache = new PhysicsDataCache();
freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero);
engine.DataCache = freshCache;
const uint movingEntityId = 0xDEADBEEFu;
var bodyPos = new Vector3(96f, 96f, 50f);

View file

@ -17,9 +17,10 @@ namespace AcDream.Core.Tests.Physics;
// re-derives the SAME (consistent) cell and never marches.
//
// These tests exercise CellTransit.FindCellSet directly with an EMPTY cache (so the
// neighbour landblock is unstreamed → the legacy path's (0,0) fallback fires) and
// compare the carried-anchor pick against the legacy pick. The cascade is direction-
// agnostic, so both a south and an east edge crossing are covered.
// neighbour landblock is unstreamed → TryGetTerrainOrigin returns false). With
// #145 Slice 3 (CarriedBlockOrigin), the explicit anchor prevents the march. With
// #145 D (2026-06-22, the TryGetTerrainOrigin-bool fix), the no-anchor path ALSO
// preserves the seed — "no frame → preserve verbatim". Both are verified here.
public class TeleportFarTownRunawayTests
{
private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu);
@ -38,10 +39,10 @@ public class TeleportFarTownRunawayTests
uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor);
Assert.Equal(0x5A, LbY(withAnchor)); // stays in 0xC95A — no march
// Legacy path (no anchor → TryGetTerrainOrigin returns (0,0) for the
// unstreamed neighbour) marches the cell one landblock south.
uint legacy = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
Assert.True(LbY(legacy) < 0x5A, $"legacy should march south of 0x5A, got 0x{LbY(legacy):X2}");
// #145 D (2026-06-22): no-anchor path also preserves when terrain is unregistered.
// Pre-D this marched south to 0x59; post-D returns currentCell unchanged.
uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
Assert.Equal(currentCell, noAnchor);
}
[Fact]
@ -57,7 +58,8 @@ public class TeleportFarTownRunawayTests
uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor);
Assert.Equal(0xCA, LbX(withAnchor)); // stays in 0xCA5B — no march
uint legacy = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
Assert.True(LbX(legacy) > 0xCA, $"legacy should march east of 0xCA, got 0x{LbX(legacy):X2}");
// #145 D: no-anchor path also preserves when terrain is unregistered.
uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null);
Assert.Equal(currentCell, noAnchor);
}
}