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

@ -1007,7 +1007,18 @@ public sealed class GameWindow : IDisposable
// the player into the void (retail loads cells synchronously;
// this is the async-streaming equivalent of that invariant).
isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null,
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
// #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
// 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.
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|| sp.Position is not { } spawnClaim
|| spawnClaim.LandblockId == 0
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)),
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@ -4852,24 +4863,42 @@ public sealed class GameWindow : IDisposable
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
bool farAway = System.Numerics.Vector3.Distance(worldPos, oldPos) > 100f;
if (differentLandblock || farAway)
// #107 (2026-06-10): ANY player position update while in PortalSpace
// IS the teleport arrival. Retail/holtburger exit portal space on the
// next position event unconditionally (holtburger messages.rs
// PlayerTeleport handler: log + LoginComplete; the destination applies
// through the normal position flow — no distance test). The old
// `differentLandblock || farAway(>100m)` arrival gate was an
// invention: ACE's same-landblock short-hop position corrections
// (e.g. right after an indoor login) matched neither condition, so
// PortalSpace never exited and movement input stayed frozen for the
// whole session (the #107 "input ignored" wedge shape —
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
{
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
// 1. Recenter the streaming controller on the new landblock.
_liveCenterX = lbX;
_liveCenterY = lbY;
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// 1. Recenter the streaming controller on the new landblock.
_liveCenterX = lbX;
_liveCenterY = lbY;
// Recompute worldPos with new center (it becomes local-to-center).
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
var newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// (after recentering, origin is (0,0,0) since lb == center)
// Recompute worldPos with new center (it becomes local-to-center).
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// (after recentering, origin is (0,0,0) since lb == center)
}
else
{
// Same landblock: worldPos is already in the current center frame.
newWorldPos = worldPos;
}
// 2. Resolve through physics for the correct ground Z.
uint newCellId = p.LandblockId;
@ -6811,11 +6840,31 @@ public sealed class GameWindow : IDisposable
{
// Convert world position back to AC wire coordinates.
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
//
// #107 (2026-06-10): the wire (cell, position) pair must be
// SELF-CONSISTENT — derive the landblock frame from the
// resolver's FULL cell id (always prefixed post-#106) instead
// of recomputing it from the position. The old composition
// welded a position-derived landblock onto result.CellId's low
// word; any tick where the two disagreed (landblock crossing,
// indoor cells near a boundary) sent ACE an id naming a
// DIFFERENT landblock's cell — and ACE persists the pair
// verbatim into the character save, which is the poisoned
// (cell-from-one-building, position-in-another) restore shape
// behind the #107 login wedge.
uint wireCellId = result.CellId;
int lbX = (int)(wireCellId >> 24);
int lbY = (int)((wireCellId >> 16) & 0xFFu);
if ((wireCellId & 0xFFFF0000u) == 0u)
{
// Defensive: bare low-word id (should not occur post-#106) —
// fall back to the position-derived landblock.
lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (wireCellId & 0xFFFFu);
}
float localX = result.Position.X - (lbX - _liveCenterX) * 192f;
float localY = result.Position.Y - (lbY - _liveCenterY) * 192f;
uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu);
var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z);
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;