fix(phys): #107 indoor-login spawn wedge — validate the server (cell,pos) pair at the player snap (retail AdjustPosition) + unfreeze same-landblock teleport arrivals + self-consistent wire pairs

Root cause (capture resolve-107-login1.jsonl + dat conformance scan): ACE
restored the player with a POISONED (cell, position) pair — cell 0xA9B40162
(one building) with a position inside 0xA9B40171 (a different building 55 m
away). Our entry snap trusted the claim verbatim: the player stood
fake-grounded for minutes (isOnGround passthrough, no contact plane, no
walkable polygon — zero-move resolves short-circuit), the FIRST movement input
ran a real transition, the pick demoted the indoor claim to outdoor
mid-building, and the player fell 2.4 m through the cottage floor onto the
terrain underneath — wedged inside the building shell. The second wedge shape
(flood-fix-gate2.log) was the PortalSpace freeze: the teleport-arrival
detection gated on `differentLandblock || farAway>100m`, an invented
heuristic — ACE's same-landblock short-hop corrections matched neither, so
PortalSpace never exited and movement input stayed frozen all session.

Four legs, all retail-anchored:

1. PhysicsEngine.Resolve (the player snap path: login entry + teleport
   arrival) now runs AdjustPosition first — retail SetPositionInternal step 1
   (acclient :283892, AdjustPosition :280009): validate/correct the claimed
   cell from the foot-sphere center BEFORE any physics. Corrections log one
   [spawn-adjust] line.
2. AdjustPosition's previously-deferred indoor seen_outside →
   adjust_to_outside sub-fallback (:280037-280046) is completed; CellPhysics
   gains the SeenOutside flag (dat EnvCellFlags.SeenOutside) cached in
   CacheCellStruct. The camera path does not reach this sub-branch in the
   gated scenarios (CameraCornerSealReplayTests green).
3. PortalSpace arrival = ANY player position update (holtburger PlayerTeleport
   handler conformant; recenter still only on landblock change). Verified
   live: ACE sent a same-lb dist=69.8 correction that the old gate would have
   frozen on — it now completes.
4. Outbound wire (cell, position) pairs are now SELF-CONSISTENT: derive the
   landblock frame from the resolver's full cell id instead of welding a
   position-derived landblock onto its low word — the old composition could
   write exactly the poisoned pair shape into ACE's character save. Plus the
   #106-gate-2 hold extension: an indoor spawn claim waits for the claimed
   cell's hydration (IsSpawnCellReady) so the validation can act — the async
   equivalent of retail's synchronous cell load.

Live verification (wedge-107-verify1.log): entry clean; ACE's same-lb teleport
correction completed (old code: permanent freeze); the teleport destination
itself carried ANOTHER poisoned claim (0xA9B40150) which [spawn-adjust]
corrected to 0xA9B40019; player fully controllable, walking across landcells.
3 new dat-backed conformance tests pin the poisoned-pair facts
(Issue107SpawnDiagnosticTests). Baseline: 1380+4 pre-existing #99-era
failures+1 skip / 223 / 420 / 294.

Pending user gate: park indoors, log out gracefully, relaunch — expect a clean
indoor spawn standing on the interior floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-10 12:52:47 +02:00
parent fb360ab3cc
commit 1090189d39
4 changed files with 287 additions and 19 deletions

View file

@ -160,6 +160,21 @@ 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)
{
@ -211,6 +226,9 @@ public sealed class PhysicsDataCache
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
// #107: retail CEnvCell.seen_outside — consumed by AdjustPosition's
// indoor not-found fallback (acclient :280037).
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
};
_cellStruct[envCellId] = cellPhysics;
@ -574,4 +592,13 @@ public sealed class CellPhysics
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
/// <summary>
/// #107: retail <c>CEnvCell.seen_outside</c> (dat <c>EnvCellFlags.SeenOutside</c>).
/// True for interiors with outdoor-visible portals (ground-floor cottage rooms).
/// <c>PhysicsEngine.AdjustPosition</c>'s indoor branch falls back to the outdoor
/// landcell under the point when the claimed cell's visible graph does not
/// contain it AND this flag is set (retail acclient :280037-280046).
/// </summary>
public bool SeenOutside { get; init; }
}