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

@ -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));
}
}