diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 24edafac..437cb422 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -582,6 +582,13 @@ public sealed class PhysicsEngine var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f); + // #111 apparatus: one [snap] line per Resolve call (entry + teleport + // arrival only — low volume, permanent). The gate-3/4/5 runs committed + // ACE's restored pair VERBATIM through this method while every read + // path should have changed Z or cell — this line answers which branch + // actually ran. Remove or demote to env-gate once #111 closes. + bool snapDiag = (delta.X == 0f && delta.Y == 0f); + // Find the landblock this candidate position falls in. // #106 follow-up (2026-06-09): capture its high-16 prefix — every // computed cell id below is returned FULL (lbPrefix | low). The old @@ -605,11 +612,47 @@ public sealed class PhysicsEngine } if (physics is null) + { + if (snapDiag) + Console.WriteLine(System.FormattableString.Invariant( + $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim")); return new ResolveResult(candidatePos, cellId, IsOnGround: false); + } float localCandX = candidatePos.X - physics.WorldOffsetX; float localCandY = candidatePos.Y - physics.WorldOffsetY; + // #111 (2026-06-10): a VALIDATED indoor claim is AUTHORITATIVE for the + // cell — retail SetPositionInternal commits the AdjustPosition cell and + // only settles Z (CheckPositionInternal → find_valid_position, :283426); + // it never re-picks the cell from floor geometry. The legacy bestCell + // floor-pick below scans EVERY CellSurface in the landblock (123 at + // Holtburg) and breaks same-height ties by iteration order — on a live + // login it clobbered ACE's clean, validated claim 0xA9B40171 with + // 0xA9B4013F (issue111-snap1.log), putting the player in a wrong cell + // → outdoor demote on first movement → transparent interior (#111). + // Snap shape only (zero delta): ground Z onto the validated claim's own + // floor when it has one under this XY; cells without their own floor + // surface here (thresholds, stair lips) fall through to the legacy path. + if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u) + { + CellSurface? claimSurface = null; + foreach (var c in physics.Cells) + { + if ((c.CellId & 0xFFFFu) == (cellId & 0xFFFFu)) { claimSurface = c; break; } + } + float? claimFloorZ = claimSurface?.SampleFloorZ(candidatePos.X, candidatePos.Y); + if (claimFloorZ is not null) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its floor z={claimFloorZ.Value:F3}")); + return new ResolveResult( + new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), + lbPrefix | (cellId & 0xFFFFu), + IsOnGround: true); + } + } + // Check if the candidate position falls on any indoor cell floor. // Pick the cell whose floor Z is closest to the entity's current Z. CellSurface? bestCell = null; @@ -757,6 +800,9 @@ public sealed class PhysicsEngine // Step-height enforcement: block upward movement that exceeds the limit. float zDelta = targetZ - currentPos.Z; + if (snapDiag) + Console.WriteLine(System.FormattableString.Invariant( + $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}")); if (zDelta > stepUpHeight) { // Too steep to step up — reject horizontal movement.