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); } }