fix(phys): #107 gate-run regression — auto-entry hold opened on a mid-population cache read; wait for the claimed cell itself

Gate-run finding (wedge-107-gate-indoor-login.log + dat scan): ACE saved the
cell one room off (claim 0xA9B40172, position inside 0xA9B40171 — adjacent
rooms). FindVisibleChildCell corrects this fine when hydrated (proven by the
new AdjacentRoomClaim regression test), but the live entry committed the raw
claim: IsSpawnCellReady's "any cell struct in the landblock => claim is bogus,
proceed" disambiguator observed the MID-POPULATION state (interiors hydrate in
id order on the background worker; the render-thread predicate read the cache
mid-loop) and opened the gate before the claim and its stab neighbors were
cached. AdjustPosition then saw a null cell struct and silently passed the
claim through; the first movement demoted the player to outdoor inside the
house — the user-visible "transparent interior, see straight through walls"
(render is downstream of membership: an outdoor-classified viewer only sees
the interior through the doorway flood).

Fix: the hold now waits for THE CLAIMED CELL's struct, full stop
(IsSpawnCellReady simplification; HasAnyCellStructInLandblock removed).
Claims that can never hydrate are filtered by GameWindow against the dat's
LandBlockInfo.NumCells range (memoized IsSpawnClaimUnhydratable), and
PhysicsEngine.Resolve carries a loud lost-cell-equivalent safety net: an
indoor claim with NO cell struct AND NO CellSurface floor data demotes to the
outdoor landcell with a [spawn-adjust] line instead of committing raw
(retail GotoLostCell :283418; documented divergence). Partial hydration
(CellSurface present, struct pending) keeps the legacy floor-snap behavior —
HasCellSurface uses the file's masked-low-word norm so bare-id fixtures and
full-id production both resolve.

Baseline restored: Core 1381 (+4 new #107 conformance tests) + 4 pre-existing
#99-era failures + 1 skip; App 223 / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-10 13:10:01 +02:00
parent 34fcbc3806
commit e4f6750e09
4 changed files with 130 additions and 24 deletions

View file

@ -430,21 +430,56 @@ public sealed class PhysicsEngine
/// green).
/// </para>
/// </summary>
/// <summary>
/// #107: does any loaded landblock carry a <see cref="CellSurface"/> for
/// this cell id? Distinguishes "partially hydrated" (floor data present,
/// struct pending — the legacy floor-snap can ground the claim) from
/// "completely unknown" (the Resolve safety net demotes loudly).
/// </summary>
private bool HasCellSurface(uint cellId)
{
// Masked low-word compare (house norm in this file): production
// CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test
// fixtures bare low words. A zero-prefix (bare, pre-#106 convention)
// claim matches any loaded landblock by low word — the legacy Resolve
// body below treats bare claims the same way.
uint low = cellId & 0xFFFFu;
uint prefix = cellId & 0xFFFF0000u;
foreach (var kvp in _landblocks)
{
if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue;
foreach (var cell in kvp.Value.Cells)
if ((cell.CellId & 0xFFFFu) == low) return true;
}
return false;
}
/// <summary>
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
/// server-claimed spawn cell is ready for <see cref="AdjustPosition"/> to
/// act on. Outdoor claims need only terrain (the existing gate). Indoor
/// claims wait until the claimed cell's struct is hydrated — the async-
/// streaming equivalent of retail's synchronous cell load before
/// SetPosition — OR until the landblock's interior batch hydrated WITHOUT
/// the claimed id (a poisoned save: proceed and let AdjustPosition demote).
/// SetPosition.
///
/// <para>
/// ⚠️ The first version disambiguated "claim bogus" via "any cell struct
/// in the landblock present" — WRONG: interiors hydrate in id order on the
/// background worker, so the render-thread predicate can observe the
/// mid-population state (early cells present, the claim not yet) and open
/// the gate before AdjustPosition's stab search can act (the 2026-06-10
/// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
/// first movement → transparent interior). Claims that can NEVER hydrate
/// (id outside the landblock's NumCells range) are now filtered by the
/// caller against the dat, and <see cref="Resolve"/> carries a loud
/// outdoor-demote safety net for any unhydrated indoor claim that still
/// gets through.
/// </para>
/// </summary>
public bool IsSpawnCellReady(uint cellId)
{
if ((cellId & 0xFFFFu) < 0x0100u) return true;
if (DataCache is null) return false;
if (DataCache.GetCellStruct(cellId) is not null) return true;
return DataCache.HasAnyCellStructInLandblock(cellId);
return DataCache?.GetCellStruct(cellId) is not null;
}
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
@ -518,6 +553,32 @@ public sealed class PhysicsEngine
$"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)"));
cellId = adjustedCellId;
}
else if (!adjustedFound
&& (cellId & 0xFFFFu) >= 0x0100u
&& DataCache?.GetCellStruct(cellId) is null
&& !HasCellSurface(cellId))
{
// #107 safety net (2026-06-10 gate-run regression): an indoor claim
// the engine knows NOTHING about (no cell struct AND no CellSurface
// floor data) cannot be validated or grounded — committing it raw
// reproduces the fake-grounded wedge. Retail goes lost-cell here
// (GotoLostCell, :283418); our recoverable equivalent is the
// outdoor landcell under the point (documented divergence — we have
// no lost-cell machinery). When only the struct is missing but the
// CellSurface floor exists (partial hydration), the legacy indoor
// floor-snap below handles the claim — don't demote. The auto-entry
// hold should make this unreachable in practice; if the line fires,
// the hold has a gap.
var (outdoorCellId, outdoorFound) = AdjustPosition(
(cellId & 0xFFFF0000u) | 0x0001u,
currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (outdoorFound)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)"));
cellId = outdoorCellId;
}
}
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);