diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 67137574..85746ee9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 67a8bcf2..02c12512 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -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. /// + /// + /// #107: true when ANY cell struct for the landblock (high-16 prefix of + /// ) 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. + /// + 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. /// public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); + + /// + /// #107: retail CEnvCell.seen_outside (dat EnvCellFlags.SeenOutside). + /// True for interiors with outdoor-visible portals (ground-floor cottage rooms). + /// PhysicsEngine.AdjustPosition'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). + /// + public bool SeenOutside { get; init; } } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 95553b6c..e21153e3 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -411,11 +411,42 @@ public sealed class PhysicsEngine /// /// SmartBox::update_viewer 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 seen_outside → - /// adjust_to_outside 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 + /// (SetPositionInternal :283908 → our ) 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. + /// + /// + /// #107 (2026-06-10) completed the previously-deferred indoor + /// seen_outside → adjust_to_outside 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). /// /// + /// + /// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the + /// server-claimed spawn cell is ready for 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). + /// + 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 /// 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. diff --git a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs new file mode 100644 index 00000000..b92fa546 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// #107 (2026-06-10): the indoor-login spawn wedge. The live capture +/// (resolve-107-login1.jsonl) showed ACE restoring the player with a POISONED +/// (cell, position) pair — cell id 0xA9B40162 (a different building, ~55 m +/// away) with a position inside cell 0xA9B40171 (the threshold cottage). The +/// unvalidated claim left the player fake-grounded (no contact plane, no +/// walkable polygon); the first movement demoted them to outdoor mid-building +/// and they fell 2.4 m through the cottage floor onto the terrain underneath. +/// +/// These tests pin the dat-level facts that drove the fix (the retail +/// AdjustPosition validation at the player snap, PhysicsEngine.Resolve head): +/// the position is genuinely inside 0x171, genuinely NOT inside 0x162's +/// visible graph, and the production pick recovers correctly from a GOOD seed +/// while demoting to outdoor from the poisoned one (retail-identical: retail's +/// find_visible_child_cell also cannot recover a cross-building claim and +/// falls to seen_outside → adjust_to_outside, acclient :280037). +/// +public sealed class Issue107SpawnDiagnosticTests +{ + private readonly ITestOutputHelper _out; + public Issue107SpawnDiagnosticTests(ITestOutputHelper output) => _out = output; + + private const float FootRadius = 0.48f; // capture input.sphereRadius + private const uint PoisonedClaim = 0xA9B40162u; // the inn-side cell ACE reported + private const uint ActualCell = 0xA9B40171u; // the cell that contains the position + + /// The captured spawn position (A9B4-local == capture world frame). + private static readonly Vector3 Spawn = new(156.53314f, 11.775104f, 96.5f); + + [Fact] + public void SpawnPosition_IsInside0171_NotInside0162_PickRecoversFromGoodSeed() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var containing = new List(); + int loaded = 0; + for (uint low = 0x0100; low <= 0x01FF; low++) + { + uint cellId = 0xA9B40000u | low; + EnvCell cell; + try { cell = ConformanceDats.LoadEnvCell(dats, cache, cellId); } + catch { continue; } + loaded++; + if (cell.PointInCell(Spawn + new Vector3(0, 0, FootRadius))) + { + containing.Add(cellId); + _out.WriteLine($"cell 0x{cellId:X8} CONTAINS spawn footCenter"); + } + } + Assert.True(loaded > 0, "expected Holtburg interior cells to load"); + + // The poisoned-pair facts: position inside 0x171, NOT inside 0x162. + Assert.Contains(ActualCell, containing); + Assert.DoesNotContain(PoisonedClaim, containing); + + var footCenter = Spawn + new Vector3(0, 0, FootRadius); + + // Production pick with the GOOD seed keeps the player indoor. + uint pickGood = CellTransit.FindCellList(cache, footCenter, FootRadius, ActualCell); + _out.WriteLine($"FindCellList(seed=0x{ActualCell:X8}) -> 0x{pickGood:X8}"); + Assert.Equal(ActualCell, pickGood); + + // Production pick with the POISONED seed demotes to the outdoor column — + // retail-identical for a cross-building claim (0x171 is not in 0x162's + // stab list). The login-side fix is AdjustPosition at the snap, which + // demotes IMMEDIATELY instead of after the first movement. + uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim); + _out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}"); + Assert.Equal(0xA9B40031u, pickBad); + } + + [Fact] + public void SeenOutside_IsPopulated_ForGroundFloorInteriors() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + ConformanceDats.LoadEnvCell(dats, cache, ActualCell); + ConformanceDats.LoadEnvCell(dats, cache, PoisonedClaim); + + var room = cache.GetCellStruct(ActualCell); + Assert.NotNull(room); + // 0x171 is a ground-floor cottage room with an exterior doorway — + // retail marks it seen_outside; AdjustPosition's indoor not-found + // fallback (acclient :280037) depends on this flag being cached. + Assert.True(room!.SeenOutside, "0xA9B40171 should be seen_outside"); + _out.WriteLine($"0x{ActualCell:X8}.SeenOutside={room.SeenOutside}"); + + var inn = cache.GetCellStruct(PoisonedClaim); + Assert.NotNull(inn); + _out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}"); + } + + [Fact] + public void PoisonedClaim_IsADifferentBuilding_55mAway() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var claim = dats.Get(PoisonedClaim); + var actual = dats.Get(ActualCell); + Assert.NotNull(claim); + Assert.NotNull(actual); + + float dist = Vector3.Distance(claim!.Position.Origin, actual!.Position.Origin); + _out.WriteLine( + $"claim origin=({claim.Position.Origin.X:F1},{claim.Position.Origin.Y:F1}) " + + $"actual origin=({actual.Position.Origin.X:F1},{actual.Position.Origin.Y:F1}) dist={dist:F1} m"); + // The pair is not a one-tick-stale neighbour confusion — the buildings + // are tens of metres apart. The poisoning happened on the WIRE side + // (the old position-derived-landblock wireCellId composition) or in a + // prior wedged session's chaos, not in the per-tick membership pick. + Assert.True(dist > 20f, $"expected cross-building distance, got {dist:F1} m"); + } +}