From 2a890e6bde8aa4d8ce4c33491c918ff3a80bfcff Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 25 May 2026 12:51:33 +0200 Subject: [PATCH] =?UTF-8?q?test(phys):=20A6.P5=20RED=20=E2=80=94=20BFS=20f?= =?UTF-8?q?rom=20indoor=20cell=20doesn't=20reach=20door=20outdoor=20cell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CellTransitTests with two A6P5_* unit tests: A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (RED — the bug) A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell (passes today) The RED test reproduces the over-penetration tick's cellSet build: sphere at (132.594, 16.350) in cell 0xA9B4013F, BFS portal-walks to 0xA9B40150 (alcove) but does NOT add the door's outdoor cell 0xA9B40029. Pre-fix cellSet: 0xA9B4013F, 0xA9B40150, 0xA9B4014C — no outdoor cells. Sphere wasn't straddling 0xA9B40150's exit portal so exitOutside stayed false. Also removes the 3 A6P5_* replay tests added to DoorBugTrajectoryReplayTests in the previous commit (3253d84's follow-up). Those tests didn't reproduce the bug — the harness's BuildFaithfulDoorEngine has no cell fixtures, so cellSet returned empty and GetNearbyObjects treated it as "no filter" → door always visible → over-penetration test trivially passed for the wrong reason. The CellTransitTests version pins the bug at the BFS layer directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/CellTransitTests.cs | 161 ++++++++++++++++++ .../Physics/DoorBugTrajectoryReplayTests.cs | 26 +++ 2 files changed, 187 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/CellTransitTests.cs diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs new file mode 100644 index 0000000..ec58db2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Tests for portal-graph expansion. +/// +/// +/// A6.P5 (2026-05-25) — unit-tests the cellSet build for the cottage door +/// scenario, where pre-fix the exitOutside gate produced over- +/// penetration ticks (sphere advanced 0.27 m INTO the door slab because +/// the door's outdoor cell wasn't in the cellSet during a cell-crossing +/// substep). +/// +/// +/// +/// Loads cells 0xA9B4013F (player's starting indoor cell at the +/// over-penetration tick) and 0xA9B40150 (alcove cell adjacent to +/// the doorway) from the real dat. Asserts that BFS expansion from +/// 0xA9B4013F reaches the door's outdoor cell 0xA9B40029 +/// via the portal chain. The fix makes this hold unconditionally; +/// pre-fix it only held when the sphere physically straddled an exit +/// portal. +/// +/// +public class CellTransitTests +{ + [Fact] + public void A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + // Hydrate the cells in the portal chain. + // 0xA9B4013F — start cell at the over-penetration tick + // 0xA9B40150 — alcove cell adjacent to doorway (has exit portal) + // The door lives in outdoor cell 0xA9B40029 (registered as a + // shadow entry on the landblock — not loaded here; this test + // only asserts the cellSet membership, not collision). + var cache = new PhysicsDataCache(); + HydrateCell(cache, datDir, 0xA9B4013Fu); + HydrateCell(cache, datDir, 0xA9B40150u); + + Assert.NotNull(cache.GetCellStruct(0xA9B4013Fu)); + Assert.NotNull(cache.GetCellStruct(0xA9B40150u)); + + // Sphere at the over-penetration tick start position: world + // (132.594, 16.350, 94.48 sphere center). Foot Z = 94, sphere + // radius 0.48 → center Z = 94 + radius? Actually the live + // capture's sphere center is at Z = 94.48 since foot Z = 94 + // and sphere is foot-anchored. For this test the Z value + // doesn't matter — only XY portal membership. + var sphereWorld = new Vector3(132.5935f, 16.350428f, 94.48f); + const float sphereRadius = 0.48f; + const uint startCellId = 0xA9B4013Fu; + const uint doorOutdoorCell = 0xA9B40029u; + + _ = CellTransit.FindCellSet( + cache, + sphereWorld, + sphereRadius, + startCellId, + out var cellSet); + + Assert.True( + cellSet.Contains(doorOutdoorCell), + $"A6.P5: BFS portal expansion from indoor start cell " + + $"0x{startCellId:X8} (sphere world XY = " + + $"({sphereWorld.X:F3}, {sphereWorld.Y:F3})) should include " + + $"the door's outdoor cell 0x{doorOutdoorCell:X8} via the " + + $"portal chain 0x{startCellId:X8} → 0xA9B40150 → outdoor. " + + $"Pre-fix: the exitOutside gate required the sphere to " + + $"straddle 0xA9B40150's exit portal — sphere is in " + + $"0x{startCellId:X8}'s volume, so the gate didn't fire and " + + $"the door's cell wasn't added. Post-fix: gate removed; " + + $"exit-portal topology adds outdoor cells unconditionally. " + + $"Actual cellSet: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}"); + } + + [Fact] + public void A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell() + { + var datDir = ResolveDatDir(); + if (datDir is null) return; + + // The alcove cell 0xA9B40150 IS the cell with the exit portal. + // Pre-fix this worked SOMETIMES (when the sphere straddled the + // portal). Post-fix it works ALWAYS. This is a regression guard + // for the previously-sometimes-working case. + var cache = new PhysicsDataCache(); + HydrateCell(cache, datDir, 0xA9B40150u); + + // Sphere at the stuck position — sphere center is NOT at the + // exit portal plane (sphere is in the alcove volume, away from + // the doorway threshold geometrically). + var sphereWorld = new Vector3(132.4014f, 16.761757f, 94.48f); + const float sphereRadius = 0.48f; + + _ = CellTransit.FindCellSet( + cache, + sphereWorld, + sphereRadius, + 0xA9B40150u, + out var cellSet); + + Assert.True( + cellSet.Contains(0xA9B40029u), + $"A6.P5: BFS from alcove cell 0xA9B40150 should always " + + $"include the door's outdoor cell 0xA9B40029. " + + $"Actual: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static string? ResolveDatDir() + { + var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(datDir) ? datDir : null; + } + + private static void HydrateCell(PhysicsDataCache cache, string datDir, uint cellId) + { + const uint EnvCellPrefix = 0x0D000000u; + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var envCell = dats.Get(cellId); + if (envCell is null) + throw new InvalidOperationException( + $"Cell 0x{cellId:X8} missing from dat at {datDir}."); + + var environment = dats.Get( + EnvCellPrefix | envCell.EnvironmentId); + if (environment is null) + throw new InvalidOperationException( + $"Environment 0x{EnvCellPrefix | envCell.EnvironmentId:X8} missing."); + + if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) + throw new InvalidOperationException( + $"CellStructure {envCell.CellStructure} missing in environment."); + + var worldTransform = + Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + + cache.CacheCellStruct(cellId, envCell, cellStruct, worldTransform); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index c6fcb91..0c933b5 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -1073,6 +1073,32 @@ public class DoorBugTrajectoryReplayTests "No captured record matched the predicate. Update the fixture."); } + /// + /// A6.P5 (2026-05-25) — loads one of the three fixed records from + /// over-penetration-capture.jsonl by index: + /// + /// 0 — the over-penetration tick (cell-crossing 0xA9B4013F → 0xA9B40150) + /// 1 — stuck-position hit=yes variant (door fired) + /// 2 — stuck-position hit=no variant (door invisible — bug case) + /// + /// + private static ResolveCaptureRecord LoadOverPenRecord(int index) + { + var path = Path.Combine(FixtureDir, "over-penetration-capture.jsonl"); + Assert.True(File.Exists(path), + $"A6.P5 over-penetration fixture missing: {path}. " + + $"Run tools/jsonl/extract-records.ps1 to rebuild."); + + var lines = File.ReadAllLines(path); + Assert.True(lines.Length >= 3, + $"Expected >= 3 records in {path}; got {lines.Length}"); + + var raw = lines[index]; + return System.Text.Json.JsonSerializer + .Deserialize(raw, + CellarUpTrajectoryReplayTests.CaptureJsonOptions)!; + } + /// /// Replays one captured ResolveWithTransition call against /// , seeded with bodyBefore, and reports