using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; using AcDream.Core.Meshing; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using DatReaderWriter.Types; using Xunit; using Xunit.Abstractions; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; using DatGfxObj = DatReaderWriter.DBObjs.GfxObj; using DatSetup = DatReaderWriter.DBObjs.Setup; namespace AcDream.App.Tests.Physics; /// /// Campaign OVERHAUL S2 chunk 1 installed-DAT comparator /// (docs/plans/2026-09-01-campaign-overhaul-world-solidity.md §9 S2 /// chunk 1; docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md /// §3). Chunk 1b threaded the whole visual part array through every /// production RegisterMultiPart caller /// (, /// its AcDream.Content.LandblockPhysicsContentBuilder.PublishStaticCollision /// no-window twin, and AcDream.Runtime.Physics.LiveEntityCollisionBuilder) /// as a zero-pixel-change side product. This suite registers real installed-DAT /// statics through that SAME dispatch — /// first, else the Setup CylSphere/Sphere fallback exactly as /// PublishStaticEntity branches, plus the /// part-array input — /// against a registry backed by the real , then /// prints and compares two answers per fixture: retail's CELLARRAY /// () and today's /// COLLISION cells (). Chunk /// 2 deleted the third "old render cells" column /// (ShadowObjectRegistry.ComputeStaticRenderCells) along with its /// only production consumer (WalkProductionWorldData.ResolveStaticRenderCells). /// Every fixture here is BSP-bearing, and chunk 1's evidence /// (`docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md` §5) /// found retail == collision == old-render exactly for all five as a /// structural consequence of the shared bbox-route primitive — so this /// asserts retail == collision directly; a genuine divergence (expected only /// for a fixture mixing colliding and non-colliding parts, none of which /// exist among these fixtures) remains chunk 3 evidence. /// [Trait("Lane", "InstalledDat")] public sealed class RetailCellArrayComparatorInstalledDatTests { private const string SkipMessage = "Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."; private readonly ITestOutputHelper _out; public RetailCellArrayComparatorInstalledDatTests(ITestOutputHelper output) => _out = output; private static string? ResolveDatDir() { var fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv; var def = Path.Combine( System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); return Directory.Exists(def) ? def : null; } // --------------------------------------------------------------- // Shared comparator machinery // --------------------------------------------------------------- private static PhysicsEngine BuildOutdoorEngine(uint landblockId) { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = -1000f; engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), 0f, 0f); return engine; } /// /// Caches every resolvable EnvCell CellStruct in [, /// ] for — the /// same pattern Issue177StairDescentCameraFloodTests.BuildHubEngine /// uses, generalized to any landblock/range so /// can traverse real portal topology for an indoor flood. /// private static PhysicsEngine BuildIndoorEngine( DatCollection dats, uint landblockId, uint lowStart, uint lowEnd) { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; for (uint low = lowStart; low <= lowEnd; low++) { uint id = landblockId | low; var datCell = dats.Get(id); if (datCell is null) continue; var environment = dats.Get( 0x0D000000u | datCell.EnvironmentId); if (environment is null) continue; if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null) { continue; } var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) * Matrix4x4.CreateTranslation(datCell.Position.Origin); cache.CacheCellStruct(id, datCell, cellStruct, world); } var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = -1000f; engine.AddLandblock(landblockId, new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), 0f, 0f); return engine; } /// /// Verbatim copy of ShadowObjectRegistry.DeriveOutdoorSeed (private /// there) — needed here so an outdoor fixture with no explicit /// seedCellId resolves the SAME seed /// would derive /// internally, for printing alongside the registered result. /// private static uint DeriveOutdoorSeedForTest( Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) { if (landblockId == 0u) return 0u; float localX = worldPos.X - worldOffsetX; float localY = worldPos.Y - worldOffsetY; int cx = (int)Math.Clamp(localX / 24f, 0f, 7f); int cy = (int)Math.Clamp(localY / 24f, 0f, 7f); uint lbPrefix = landblockId & 0xFFFF0000u; return lbPrefix | (uint)(cx * 8 + cy + 1); } private readonly record struct ComparatorOutcome( string Label, uint EntityId, IReadOnlyList RetailCells, IReadOnlyList CollisionCells, RetailCellArrayRoute Route, int PartCount, int BspShapeCount, bool CollisionRegistered); /// /// Registers one static through the REAL production dispatch — /// first, else the /// Setup CylSphere/Sphere fallback, exactly the branch order /// LandblockPhysicsPublisher.PublishStaticEntity uses — plus the /// chunk-1b whole-part-array side product /// (), then reads /// back and prints both comparator answers. /// private ComparatorOutcome RegisterAndCompare( string label, PhysicsEngine engine, PhysicsDataCache cache, uint entityId, IReadOnlyList meshRefs, DatSetup? setupFallback, Vector3 worldPos, Quaternion worldRot, float worldOffsetX, float worldOffsetY, uint landblockId, uint seedCellId) { IReadOnlyList bspShapes = ShadowShapeBuilder.FromLandblockBspParts( meshRefs, isBuildingShell: false, cache.GetGfxObj); IReadOnlyList partArray = ShadowShapeBuilder.FromStaticRenderParts( meshRefs, cache.GetGfxObj, cache.GetVisualBounds, out bool hasPhysicsBsp); uint resolvedSeed = seedCellId != 0u ? seedCellId : DeriveOutdoorSeedForTest(worldPos, worldOffsetX, worldOffsetY, landblockId); bool registered = false; if (bspShapes.Count > 0) { engine.ShadowObjects.RegisterMultiPart( entityId, worldPos, worldRot, bspShapes, 0u, EntityCollisionFlags.None, worldOffsetX, worldOffsetY, landblockId, seedCellId: resolvedSeed, isStatic: true, partArray: partArray); registered = true; } else if (setupFallback is not null) { FlatSetupCollision flatSetup = FlatCollisionAssetBuilder.FlattenSetup(setupFallback); const float scale = 1f; var setupShapes = new List(); for (int i = 0; i < flatSetup.Cylinders.Length; i++) { FlatCollisionCylinder cyl = flatSetup.Cylinders[i]; float radius = cyl.Radius * scale; float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; if (radius <= 0f) continue; setupShapes.Add(ShadowShape.Cylinder( gfxObjId: 0u, localPosition: cyl.Origin * scale, localRotation: Quaternion.Identity, scale: scale, radius: radius, cylHeight: baseHeight * scale)); } if (flatSetup.Cylinders.Length == 0) { for (int i = 0; i < flatSetup.Spheres.Length; i++) { FlatCollisionSphere sph = flatSetup.Spheres[i]; if (sph.Radius <= 0f) continue; setupShapes.Add(ShadowShape.Sphere( gfxObjId: 0u, localPosition: sph.Origin * scale, localRotation: Quaternion.Identity, scale: scale, radius: sph.Radius * scale)); } } if (setupShapes.Count > 0) { engine.ShadowObjects.RegisterMultiPart( entityId, worldPos, worldRot, setupShapes, 0u, EntityCollisionFlags.None, worldOffsetX, worldOffsetY, landblockId, seedCellId: resolvedSeed, isStatic: true, partArray: partArray); registered = true; } } engine.ShadowObjects.TryGetRetailCellArray(entityId, out IReadOnlyList retailCells); IReadOnlyList collisionCells = engine.ShadowObjects.GetOwnerCells(entityId); RetailCellArrayRoute route = engine.ShadowObjects.GetRetailCellArrayRoute(entityId); PrintOutcome( label, entityId, retailCells, collisionCells, route, partArray.Count, bspShapes.Count, hasPhysicsBsp, registered, resolvedSeed); return new ComparatorOutcome( label, entityId, retailCells, collisionCells, route, partArray.Count, bspShapes.Count, registered); } private void PrintOutcome( string label, uint entityId, IReadOnlyList retailCells, IReadOnlyList collisionCells, RetailCellArrayRoute route, int partCount, int bspShapeCount, bool hasPhysicsBsp, bool collisionRegistered, uint seedCellId) { static string CellSet(IEnumerable ids) => "[" + string.Join(",", ids.Select(id => FormattableString.Invariant($"0x{id:X8}"))) + "]"; _out.WriteLine(FormattableString.Invariant( $"--- {label} (entity=0x{entityId:X8} seed=0x{seedCellId:X8}) ---")); _out.WriteLine(FormattableString.Invariant( $" parts={partCount} bspShapes={bspShapeCount} hasPhysicsBsp={hasPhysicsBsp} collisionRegistered={collisionRegistered} route={route}")); _out.WriteLine($" retail n={retailCells.Count} {CellSet(retailCells)}"); _out.WriteLine($" collision n={collisionCells.Count} {CellSet(collisionCells)}"); foreach (uint cellId in retailCells) { int entries = 0; foreach (RetailPartEntry entry in _lastEngine!.ShadowObjects.GetRetailPartEntriesInCell(cellId)) if (entry.EntityId == entityId) entries++; _out.WriteLine(FormattableString.Invariant( $" cell 0x{cellId:X8}: {entries} retail part entries")); } IEnumerable retailOnly = retailCells.Except(collisionCells); IEnumerable collisionOnly = collisionCells.Except(retailCells); if (retailOnly.Any() || collisionOnly.Any()) { _out.WriteLine( $" retail-vs-collision difference: " + $"retailOnly={CellSet(retailOnly)} collisionOnly={CellSet(collisionOnly)}"); } } // PrintOutcome needs the engine to look up per-cell entries; stash it for // the duration of one fixture's call rather than widen every signature. private PhysicsEngine? _lastEngine; private ComparatorOutcome RunFixture( string label, PhysicsEngine engine, PhysicsDataCache cache, uint entityId, IReadOnlyList meshRefs, DatSetup? setupFallback, Vector3 worldPos, Quaternion worldRot, float worldOffsetX, float worldOffsetY, uint landblockId, uint seedCellId) { _lastEngine = engine; return RegisterAndCompare( label, engine, cache, entityId, meshRefs, setupFallback, worldPos, worldRot, worldOffsetX, worldOffsetY, landblockId, seedCellId); } private static void AssertSameCells(IReadOnlyList retail, IReadOnlyList collision) { Assert.Equal( collision.OrderBy(id => id).ToArray(), retail.OrderBy(id => id).ToArray()); } // --------------------------------------------------------------- // Fixture 1 — Facility Hub stair Setup 0x02000623 (0x8A02015F/0x8A02015E) // --------------------------------------------------------------- [Fact] public void FacilityHubStair_RetailCellArrayMatchesCollisionCells() { string? datDir = ResolveDatDir(); if (datDir is null) Assert.Fail(SkipMessage); using var dats = new DatCollection(datDir, DatAccessType.Read); const uint FacilityHub = 0x8A020000u; const uint setupId = 0x02000623u; PhysicsEngine engine = BuildIndoorEngine(dats, FacilityHub, 0x0100u, 0x01FFu); var cache = (PhysicsDataCache)engine.DataCache!; DatSetup setup = Assert.IsType(dats.Get(setupId)); IReadOnlyList meshRefs = SetupMesh.Flatten(setup); foreach (MeshRef meshRef in meshRefs) { DatGfxObj gfxObj = Assert.IsType(dats.Get(meshRef.GfxObjId)); cache.CacheGfxObj(meshRef.GfxObjId, gfxObj); } DatEnvCell parent = Assert.IsType( dats.Get(FacilityHub | 0x015Fu)); var stair = Assert.Single(parent.StaticObjects, s => s.Id == setupId); Vector3 worldPos = new( stair.Frame.Origin.X, stair.Frame.Origin.Y, stair.Frame.Origin.Z); Quaternion worldRot = stair.Frame.Orientation; const uint entityId = 0x7F000001u; ComparatorOutcome outcome = RunFixture( "1: Facility Hub stair Setup 0x02000623", engine, cache, entityId, meshRefs, setup, worldPos, worldRot, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: FacilityHub, seedCellId: FacilityHub | 0x015Fu); Assert.Contains(FacilityHub | 0x015Fu, outcome.RetailCells); Assert.Contains(FacilityHub | 0x015Eu, outcome.RetailCells); AssertSameCells(outcome.RetailCells, outcome.CollisionCells); } // --------------------------------------------------------------- // Fixture 2 — Cathedral ramp Setup 0x020009A2 (landblock 0xF418) // --------------------------------------------------------------- private static (uint ParentCellId, Stab Stab)? FindStaticParent( DatCollection dats, uint landblockId, uint staticId, uint lowStart, uint lowEnd) { for (uint low = lowStart; low <= lowEnd; low++) { uint id = landblockId | low; DatEnvCell? cell = dats.Get(id); if (cell?.StaticObjects is null) continue; foreach (Stab stab in cell.StaticObjects) if (stab.Id == staticId) return (id, stab); } return null; } [Fact] public void CathedralRamp_RetailCellArrayMatchesCollisionCells() { string? datDir = ResolveDatDir(); if (datDir is null) Assert.Fail(SkipMessage); using var dats = new DatCollection(datDir, DatAccessType.Read); const uint Cathedral = 0xF4180000u; const uint setupId = 0x020009A2u; // docs/research has no named cell/position for this static; the two // render buckets it crosses (RetailPViewRenderer's cathedralFlood // probe: 0xF4180107 / 0xF4180112) are both in the 0x0100-0x01FF // range — the same convention Facility Hub uses — so the parent scan // is bounded there first. (uint ParentCellId, Stab Stab)? found = FindStaticParent(dats, Cathedral, setupId, 0x0100u, 0x01FFu) ?? FindStaticParent(dats, Cathedral, setupId, 0x0200u, 0x03FFu); Assert.True(found is not null, "Cathedral ramp 0x020009A2 parent EnvCell not found in 0x0100-0x03FF."); (uint parentCellId, Stab stab) = found!.Value; _out.WriteLine(FormattableString.Invariant( $" resolved cathedral ramp parent cell = 0x{parentCellId:X8}")); PhysicsEngine engine = BuildIndoorEngine(dats, Cathedral, 0x0100u, 0x03FFu); var cache = (PhysicsDataCache)engine.DataCache!; DatSetup setup = Assert.IsType(dats.Get(setupId)); IReadOnlyList meshRefs = SetupMesh.Flatten(setup); foreach (MeshRef meshRef in meshRefs) { DatGfxObj gfxObj = Assert.IsType(dats.Get(meshRef.GfxObjId)); cache.CacheGfxObj(meshRef.GfxObjId, gfxObj); } Vector3 worldPos = new(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z); Quaternion worldRot = stab.Frame.Orientation; const uint entityId = 0x7F000002u; ComparatorOutcome outcome = RunFixture( "2: Cathedral ramp Setup 0x020009A2", engine, cache, entityId, meshRefs, setup, worldPos, worldRot, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: Cathedral, seedCellId: parentCellId); AssertSameCells(outcome.RetailCells, outcome.CollisionCells); } // --------------------------------------------------------------- // Fixture 3 — #334 Neftet formation GfxObj 0x010046D8 (landblock 0x8764) // --------------------------------------------------------------- [Fact] public void NeftetFormation_RetailCellArrayMatchesCollisionCells() { string? datDir = ResolveDatDir(); if (datDir is null) Assert.Fail(SkipMessage); using var dats = new DatCollection(datDir, DatAccessType.Read); const uint NeftetLandblock = 0x87640000u; const uint NeftetLandblockInfo = 0x8764FFFEu; const uint FormationGfxObj = 0x010046D8u; LandBlockInfo? info = dats.Get(NeftetLandblockInfo); Assert.NotNull(info); Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj); DatGfxObj gfx = Assert.IsType(dats.Get(FormationGfxObj)); PhysicsEngine engine = BuildOutdoorEngine(NeftetLandblock); var cache = (PhysicsDataCache)engine.DataCache!; cache.CacheGfxObj(FormationGfxObj, gfx); var meshRefs = new List { new(FormationGfxObj, Matrix4x4.Identity) }; Vector3 worldPos = formation.Frame.Origin; Quaternion worldRot = formation.Frame.Orientation; const uint entityId = 0x7F000003u; ComparatorOutcome outcome = RunFixture( "3: #334 Neftet formation GfxObj 0x010046D8", engine, cache, entityId, meshRefs, setupFallback: null, worldPos, worldRot, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: NeftetLandblock, seedCellId: 0u); AssertSameCells(outcome.RetailCells, outcome.CollisionCells); } // --------------------------------------------------------------- // Fixture 4 — one outdoor static crossing a landblock edge. Neither // LandblockPhysicsPublisherTests nor WalkProductionWorldDataTests carries // a real installed-DAT edge-crossing static (their outdoor fixtures are // synthetic cell-id lists, no DAT resolution) — this scans a real // landblock's Objects for a GfxObj-class stab whose cached authored // vertex-array BOX (CGfxObj::gfx_bound_box) overlaps its own landblock's // 0/192 m boundary. The box, not the root bounding sphere, is what // governs here: CLandCell::add_all_outside_cells divides the BOX by // square_length (#334) — the sphere is typically much looser (a first // pass keyed on BoundingSphere.Radius picked the #334 Neftet formation // itself, whose 69 m sphere radius crosses 0 m at its 63.78 m origin but // whose real 96 m box does not — see NeftetFormation_RetailCellArrayMatchesCollisionCells, // which already covers that object; this scan explicitly excludes it so // fixture 4 stays independent of fixture 3). // --------------------------------------------------------------- private static (uint Id, Stab Stab)? FindEdgeCrossingGfxObjStatic( DatCollection dats, PhysicsDataCache scanCache, uint landblockInfoId, uint excludeGfxObjId) { LandBlockInfo? info = dats.Get(landblockInfoId); if (info is null) return null; foreach (Stab stab in info.Objects) { if ((stab.Id & 0xFF000000u) != 0x01000000u) continue; // GfxObj-class only. if (stab.Id == excludeGfxObjId) continue; DatGfxObj? gfx = dats.Get(stab.Id); if (gfx is null) continue; scanCache.CacheGfxObj(stab.Id, gfx); GfxObjPhysics? physics = scanCache.GetGfxObj(stab.Id); if (physics?.BSP?.Root is null) continue; // must take the BSP route. if (physics.VisualBounds is not { } box) continue; float x = stab.Frame.Origin.X; float y = stab.Frame.Origin.Y; bool crosses = x + box.Min.X < 0f || x + box.Max.X > 192f || y + box.Min.Y < 0f || y + box.Max.Y > 192f; if (crosses) return (stab.Id, stab); } return null; } [Fact] public void OutdoorLandblockEdgeCrosser_RetailCellArrayMatchesCollisionCells() { string? datDir = ResolveDatDir(); if (datDir is null) Assert.Fail(SkipMessage); using var dats = new DatCollection(datDir, DatAccessType.Read); const uint ArwicLandblock = 0xC6A90000u; const uint ArwicLandblockInfo = 0xC6A9FFFEu; const uint NeftetLandblock = 0x87640000u; const uint NeftetLandblockInfo = 0x8764FFFEu; const uint NeftetFormationGfxObj = 0x010046D8u; // fixture 3's own object — excluded here. var scanCache = new PhysicsDataCache(); (uint Id, Stab Stab)? candidate = FindEdgeCrossingGfxObjStatic(dats, scanCache, ArwicLandblockInfo, NeftetFormationGfxObj); uint landblockId = ArwicLandblock; if (candidate is null) { candidate = FindEdgeCrossingGfxObjStatic( dats, scanCache, NeftetLandblockInfo, NeftetFormationGfxObj); landblockId = NeftetLandblock; } Assert.True( candidate is not null, "No BSP-bearing edge-crossing GfxObj static found scanning Arwic/Neftet Objects."); (uint gfxObjId, Stab stab) = candidate!.Value; _out.WriteLine(FormattableString.Invariant( $" resolved edge crosser: landblock=0x{landblockId:X8} gfxObj=0x{gfxObjId:X8} origin=({stab.Frame.Origin.X:F2},{stab.Frame.Origin.Y:F2})")); DatGfxObj gfx = Assert.IsType(dats.Get(gfxObjId)); PhysicsEngine engine = BuildOutdoorEngine(landblockId); var cache = (PhysicsDataCache)engine.DataCache!; cache.CacheGfxObj(gfxObjId, gfx); var meshRefs = new List { new(gfxObjId, Matrix4x4.Identity) }; Vector3 worldPos = stab.Frame.Origin; Quaternion worldRot = stab.Frame.Orientation; const uint entityId = 0x7F000004u; ComparatorOutcome outcome = RunFixture( "4: outdoor landblock-edge crosser", engine, cache, entityId, meshRefs, setupFallback: null, worldPos, worldRot, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: landblockId, seedCellId: 0u); IEnumerable foreignLandblockCells = outcome.RetailCells .Where(id => (id & 0xFFFF0000u) != landblockId); _out.WriteLine( $" cells in a NEIGHBOR landblock (edge-crossing evidence): " + $"[{string.Join(",", foreignLandblockCells.Select(id => FormattableString.Invariant($"0x{id:X8}")))}]"); AssertSameCells(outcome.RetailCells, outcome.CollisionCells); } // --------------------------------------------------------------- // Fixture 5 — multi-part Setup with parts crossing different cells. // CORRECTED against the actual installed DAT (not the plan packet's // assumption): Facility Hub stair Setup 0x02000623 (fixture 1) resolves // to exactly ONE visual part (SetupMesh.Flatten count = 1) whose own // wide bounding box spans all 7 crossed cells — it is a wide SINGLE-part // static, not a multi-part one. Cathedral ramp Setup 0x020009A2 // (fixture 2) genuinely IS the multi-part case: parts=7 (the code // comment at WbDrawDispatcher.WalkClassify.cs:599 names them — // 0x01001FE8 + six 0x01001FE6 slabs). retail's per-cell part entries for // fixture 2 show all 7 parts in all 3 crossed cells (0xF4180112, // 0xF4180113, 0xF4180009) — retail's CPartArray::AddPartsShadow registers // EVERY part of the entity's part array into EVERY cell of the entity's // (union) CELLARRAY, not a per-part cell subset; "parts in different // cells" is therefore the ENTITY's CELLARRAY spanning multiple cells // (which fixture 2 does, 3 of them), not individual parts each confined // to their own cell. No installed-DAT fixture with genuinely // per-part-confined membership was found among the existing test files // this chunk was pointed at (Issue177StairDescentCameraFloodTests, // LandblockPhysicsPublisherTests, Issue334NeftetFormationCellMembershipTests, // WalkProductionWorldDataTests) — fixture 2's own result is the evidence // for this row. // --------------------------------------------------------------- }