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:
parent
34fcbc3806
commit
e4f6750e09
4 changed files with 130 additions and 24 deletions
|
|
@ -1012,13 +1012,15 @@ public sealed class GameWindow : IDisposable
|
|||
// additionally waits for the claimed cell's hydration so the
|
||||
// entry snap's AdjustPosition validation can act (retail loads
|
||||
// the cell synchronously before SetPosition; this is the
|
||||
// async-streaming equivalent). A claim whose landblock's
|
||||
// interior batch hydrated WITHOUT it is poisoned — proceed
|
||||
// and let AdjustPosition demote it.
|
||||
// async-streaming equivalent). Claims that can never hydrate
|
||||
// (id outside the landblock's NumCells range per the dat)
|
||||
// don't hold the gate — the Resolve-head safety net demotes
|
||||
// them loudly.
|
||||
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
|| sp.Position is not { } spawnClaim
|
||||
|| spawnClaim.LandblockId == 0
|
||||
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)),
|
||||
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
|
||||
|| IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
|
||||
enterPlayerMode: EnterPlayerModeFromAutoEntry);
|
||||
}
|
||||
|
||||
|
|
@ -11305,6 +11307,34 @@ public sealed class GameWindow : IDisposable
|
|||
/// the guard already guarantee <c>_entitiesByServerGuid</c> contains
|
||||
/// the player guid, so the inner TryGetValue is a fast-path success.
|
||||
/// </summary>
|
||||
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
|
||||
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
|
||||
// hold so a garbage claim doesn't stall login forever; the Resolve-head
|
||||
// safety net demotes it loudly once entry proceeds.
|
||||
private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
|
||||
|
||||
private bool IsSpawnClaimUnhydratable(uint claim)
|
||||
{
|
||||
if ((claim & 0xFFFFu) < 0x0100u) return false;
|
||||
if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
|
||||
|
||||
bool unhydratable = false;
|
||||
if (_dats is not null)
|
||||
{
|
||||
DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
|
||||
lock (_datLock)
|
||||
{
|
||||
lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(claim & 0xFFFF0000u) | 0xFFFEu);
|
||||
}
|
||||
uint low = claim & 0xFFFFu;
|
||||
unhydratable = lbInfo is null || lbInfo.NumCells == 0
|
||||
|| low >= 0x0100u + lbInfo.NumCells;
|
||||
}
|
||||
_spawnClaimRangeMemo = (claim, unhydratable);
|
||||
return unhydratable;
|
||||
}
|
||||
|
||||
private void EnterPlayerModeFromAutoEntry()
|
||||
{
|
||||
_playerMode = true;
|
||||
|
|
|
|||
|
|
@ -160,21 +160,6 @@ public sealed class PhysicsDataCache
|
|||
/// (indoor room geometry). No-ops if the id is already cached or the
|
||||
/// CellStruct has no physics BSP.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #107: true when ANY cell struct for the landblock (high-16 prefix of
|
||||
/// <paramref name="cellId"/>) is cached. Cell structs for a landblock are
|
||||
/// registered as a batch by the streaming completion, so "some cells present
|
||||
/// but the claimed one absent" means the claimed id is bogus (poisoned save)
|
||||
/// rather than still-streaming.
|
||||
/// </summary>
|
||||
public bool HasAnyCellStructInLandblock(uint cellId)
|
||||
{
|
||||
uint prefix = cellId & 0xFFFF0000u;
|
||||
foreach (var key in _cellStruct.Keys)
|
||||
if ((key & 0xFFFF0000u) == prefix) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
|
||||
CellStruct cellStruct, Matrix4x4 worldTransform)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,36 @@ public sealed class Issue107SpawnDiagnosticTests
|
|||
_out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentRoomClaim_CorrectsToContainingRoom_ViaStabList()
|
||||
{
|
||||
// 2026-06-10 gate-run regression: ACE saved the cell one room off
|
||||
// (claim 0xA9B40172, position inside 0xA9B40171 — adjacent rooms of the
|
||||
// threshold cottage, connected via the 20 cm threshold cell 0x173).
|
||||
// With every cell hydrated, retail's find_visible_child_cell stab-list
|
||||
// search (acclient :311444) must correct the claim. The live failure
|
||||
// was the auto-entry hold opening before 0x171 hydrated (the
|
||||
// mid-population "any cell present" disambiguator, since removed) —
|
||||
// this test pins the hydrated-path correctness.
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cache = new PhysicsDataCache();
|
||||
for (uint low = 0x0100; low <= 0x01FF; low++)
|
||||
{
|
||||
try { ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40000u | low); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
// The gate-run spawn point (ACE save: cell 0x172, position in 0x171).
|
||||
var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius);
|
||||
|
||||
uint corrected = CellTransit.FindVisibleChildCell(
|
||||
cache, 0xA9B40172u, spawnFootCenter, useStabList: true);
|
||||
_out.WriteLine($"FindVisibleChildCell(claim 0x172, stab) -> 0x{corrected:X8}");
|
||||
Assert.Equal(ActualCell, corrected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PoisonedClaim_IsADifferentBuilding_55mAway()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue