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

@ -411,11 +411,42 @@ public sealed class PhysicsEngine
/// <para>
/// <c>SmartBox::update_viewer</c> calls this to seat the camera sweep's start
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
/// at the sought eye (:280078). Retail's indoor <c>seen_outside →
/// adjust_to_outside</c> sub-fallback (:280037-280046) is deferred — not on the
/// cottage/cellar camera path (see the design spec §6).
/// at the sought eye (:280078). The player snap path
/// (<c>SetPositionInternal</c> :283908 → our <see cref="Resolve"/>) calls it to
/// validate the server-restored (cell, position) pair before any physics runs —
/// the #107 indoor-login wedge was this validation missing: a poisoned save
/// (cell id from one building, position inside another) was trusted verbatim,
/// the player stood fake-grounded with no walkable floor, and the first movement
/// demoted them outdoor mid-building → 2.4 m fall under the cottage floor.
/// </para>
/// <para>
/// #107 (2026-06-10) completed the previously-deferred indoor
/// <c>seen_outside → adjust_to_outside</c> sub-fallback (:280037-280046): when
/// the claimed cell is hydrated, nothing in its visible graph contains the
/// point, and the cell has outdoor-visible portals, retail demotes to the
/// landcell under the point. The corner-seal replay (`b21bb28`) shows camera
/// eyes always land inside cells/openings, so the camera path does not reach
/// this sub-branch in the gated scenarios (CameraCornerSealReplayTests stays
/// green).
/// </para>
/// </summary>
/// <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).
/// </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);
}
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
{
if (seedCellId == 0u) return (seedCellId, false);
@ -425,7 +456,15 @@ public sealed class PhysicsEngine
// Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028).
if (DataCache is null) return (seedCellId, false);
uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true);
return child != 0u ? (child, true) : (seedCellId, false);
if (child != 0u) return (child, true);
// Retail :280037-280046: claimed cell hydrated + seen_outside →
// Position::adjust_to_outside (fall through to the grid snap below).
// A non-hydrated or not-seen-outside claim stays (seed, false) —
// retail's lost-cell path; our callers keep their legacy fallback.
var claimed = DataCache.GetCellStruct(seedCellId);
if (claimed is null || !claimed.SeenOutside)
return (seedCellId, false);
}
// Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the
@ -460,6 +499,26 @@ public sealed class PhysicsEngine
/// </summary>
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
// #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892)
// step 1 — AdjustPosition (:283908) validates/corrects the claimed cell
// from the position BEFORE any physics runs. This legacy Resolve is the
// player snap path (login entry + teleport arrival — the SetPosition
// shaped calls); both hand it a server-restored (cell, position) pair
// that can be poisoned (the #107 capture: cell id from one building,
// position inside another, 55 m apart). Retail validates at the foot-
// sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903);
// the player's foot sphere is radius 0.48 m centred 0.48 m above the
// feet (PlayerMovementController body — capture input.sphereRadius).
const float FootSphereCenterLift = 0.48f;
var (adjustedCellId, adjustedFound) = AdjustPosition(
cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (adjustedFound && adjustedCellId != cellId)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[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;
}
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// Find the landblock this candidate position falls in.