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:
parent
fb360ab3cc
commit
1090189d39
4 changed files with 287 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue