diff --git a/src/AcDream.App/Rendering/Walk/WalkWorldDatAdapter.cs b/src/AcDream.App/Rendering/Walk/WalkWorldDatAdapter.cs new file mode 100644 index 00000000..767abf72 --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkWorldDatAdapter.cs @@ -0,0 +1,211 @@ +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Environment = DatReaderWriter.DBObjs.Environment; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign FW1 — builds the walk's world model from real DAT data. The +/// library parses everything the walk needs (survey 2026-08-30: PORT nodes +/// with PortalRef{PolyId, PortalIndex} in retail field order, cell +/// portal records with the PortalSide flag bit, building portals with stab +/// lists); this adapter is the previously missing consumer. +/// +/// Conventions (retail structs; the trace fixtures are the referee): +/// +/// portal_side = (Flags & 0x2) != 0 (PortalFlags.PortalSide), +/// matching EnvCellLandblockBuild's production decode. +/// Portal-polygon planes use the first-three-vertices cross with +/// d = −dot(N, p0) — the same formula the production visibility +/// cell build has always used against retail's InitCell side test. +/// A building's event id is its containing outdoor landcell: +/// low word = cellX·8 + cellY + 1 at 24 m pitch. +/// +/// +public static class WalkWorldDatAdapter +{ + public static WalkCell? BuildCell(DatCollection dats, uint cellId) + { + if (dats.Get(cellId) is not EnvCell envCell) + return null; + if (dats.Get(0x0D000000u | envCell.EnvironmentId) is not Environment environment + || !environment.Cells.TryGetValue(envCell.CellStructure, out CellStruct? cellStruct) + || cellStruct is null) + { + return null; + } + + uint lbMask = cellId & 0xFFFF0000u; + int portalCount = envCell.CellPortals.Count; + var portals = new WalkCellPortal[portalCount]; + var polygons = new WalkPolygon[portalCount]; + for (int i = 0; i < portalCount; i++) + { + CellPortal portal = envCell.CellPortals[i]; + portals[i] = new WalkCellPortal + { + OtherCellId = portal.OtherCellId == 0xFFFF + ? 0xFFFFFFFFu + : lbMask | portal.OtherCellId, + PolygonIndex = i, + PortalSide = ((ushort)portal.Flags & 0x2) != 0 ? 1 : 0, + ExactMatch = ((ushort)portal.Flags & 0x1) != 0, + OtherPortalId = unchecked((short)portal.OtherPortalId), + }; + polygons[i] = BuildPolygon(cellStruct, portal.PolygonId) + ?? new WalkPolygon(); + } + + return new WalkCell + { + CellId = cellId, + Portals = portals, + PortalPolygons = polygons, + StabList = envCell.VisibleCells.Select(v => lbMask | v).ToArray(), + }; + } + + public static Dictionary BuildInteriorCells( + DatCollection dats, uint landblockId) + { + uint lbMask = landblockId & 0xFFFF0000u; + var cells = new Dictionary(); + for (uint low = 0x0100; low <= 0xFFFD; low++) + { + WalkCell? cell = BuildCell(dats, lbMask | low); + if (cell is null) break; // interior cells are contiguous from 0x0100 + cells[cell.CellId] = cell; + } + return cells; + } + + /// Buildings of one landblock, with their walk event ids + /// (containing landcell), portal records, and drawing-BSP portal trees. + public static List BuildBuildings(DatCollection dats, uint landblockId) + { + uint lbMask = landblockId & 0xFFFF0000u; + var result = new List(); + if (dats.Get(lbMask | 0xFFFEu) is not LandBlockInfo info + || info.Buildings is null) + { + return result; + } + + foreach (BuildingInfo buildingInfo in info.Buildings) + { + Vector3 origin = new( + buildingInfo.Frame.Origin.X, + buildingInfo.Frame.Origin.Y, + buildingInfo.Frame.Origin.Z); + int cellX = (int)MathF.Floor(origin.X / 24f); + int cellY = (int)MathF.Floor(origin.Y / 24f); + uint positionCellId = lbMask | (uint)(cellX * 8 + cellY + 1); + + var portals = new WalkBldPortal[buildingInfo.Portals.Count]; + for (int i = 0; i < portals.Length; i++) + { + BuildingPortal portal = buildingInfo.Portals[i]; + portals[i] = new WalkBldPortal + { + PortalSide = ((ushort)portal.Flags & 0x2) != 0 ? 1 : 0, + ExactMatch = ((ushort)portal.Flags & 0x1) != 0, + OtherCellId = portal.OtherCellId == 0xFFFF + ? 0xFFFFFFFFu + : lbMask | portal.OtherCellId, + OtherPortalId = unchecked((short)portal.OtherPortalId), + StabList = portal.StabList.Select(s => lbMask | s).ToArray(), + }; + } + + WalkBspNode? bsp = null; + if (dats.Get(buildingInfo.ModelId) is GfxObj gfxObj) + bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root); + + result.Add(new WalkBuilding + { + PositionCellId = positionCellId, + Portals = portals, + DrawingBsp = bsp, + }); + } + return result; + } + + internal static WalkBspNode? ConvertDrawingBsp(GfxObj gfxObj, DrawingBSPNode? node) + { + if (node is null) return null; + var converted = new WalkBspNode + { + SplittingPlane = new WalkPlane( + node.SplittingPlane.Normal, node.SplittingPlane.D), + IsFail = node.Type == DatReaderWriter.Enums.BSPNodeType.Leaf, + PosNode = ConvertDrawingBsp(gfxObj, node.PosNode), + NegNode = ConvertDrawingBsp(gfxObj, node.NegNode), + }; + if (node.Type == DatReaderWriter.Enums.BSPNodeType.Portal && node.Portals is not null) + { + var refs = new List(node.Portals.Count); + foreach (PortalRef portalRef in node.Portals) + { + WalkPolygon? polygon = BuildGfxPolygon(gfxObj, portalRef.PolyId); + if (polygon is not null) + refs.Add(new WalkPortalRef + { + PortalIndex = portalRef.PortalIndex, + Polygon = polygon, + }); + } + converted.InPortals = refs.ToArray(); + } + return converted; + } + + private static WalkPolygon? BuildPolygon(CellStruct cellStruct, ushort polygonId) + { + if (!cellStruct.Polygons.TryGetValue(polygonId, out Polygon? poly) + || poly is null || poly.VertexIds.Count < 3) + { + return null; + } + return BuildPolygonFromVertices( + poly.VertexIds, + id => cellStruct.VertexArray.Vertices.TryGetValue((ushort)id, out SWVertex? v) + ? new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z) + : null); + } + + private static WalkPolygon? BuildGfxPolygon(GfxObj gfxObj, ushort polygonId) + { + if (!gfxObj.Polygons.TryGetValue(polygonId, out Polygon? poly) + || poly is null || poly.VertexIds.Count < 3) + { + return null; + } + return BuildPolygonFromVertices( + poly.VertexIds, + id => gfxObj.VertexArray.Vertices.TryGetValue((ushort)id, out SWVertex? v) + ? new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z) + : null); + } + + private static WalkPolygon? BuildPolygonFromVertices( + IReadOnlyList vertexIds, Func resolve) + { + var vertices = new Vector3[vertexIds.Count]; + for (int i = 0; i < vertexIds.Count; i++) + { + Vector3? v = resolve(vertexIds[i]); + if (v is null) return null; + vertices[i] = v.Value; + } + Vector3 normal = Vector3.Normalize( + Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0])); + return new WalkPolygon + { + Vertices = vertices, + Plane = new WalkPlane(normal, -Vector3.Dot(normal, vertices[0])), + }; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs new file mode 100644 index 00000000..38fb831a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapterTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.Tests.Rendering; +using AcDream.App.Rendering.Walk; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// Ground-truth conformance for the DAT adapter: every building the FW0 +/// oracle traces saw retail draw (the BLD event ids, captured live +/// 2026-08-30) must exist in the adapter's roster for its landblock with +/// the EXACT position cell id, and the look-in cells the traces name must +/// build with portals and polygons. This validates the ModelId/Frame → +/// landcell computation and the cell extraction against retail's own +/// output — no camera pose required. +/// +[Trait("Lane", "InstalledDat")] +public sealed class WalkWorldDatAdapterTests +{ + private static DatCollection OpenDats() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + } + return new DatCollection(datDir!, DatAccessType.Read); + } + + public static readonly TheoryData TraceBuildingRosters = new() + { + { + 0xF4180000u, + new[] + { + 0xF4180011u, 0xF4180001u, 0xF4180009u, 0xF4180002u, 0xF4180004u, + 0xF4180014u, 0xF418000Au, 0xF418000Cu, 0xF418000Bu, + } + }, + { 0xF5180000u, new[] { 0xF518002Eu } }, + { 0xF4170000u, new[] { 0xF4170011u, 0xF4170012u, 0xF4170002u } }, + { 0xF3180000u, new[] { 0xF3180020u } }, + { + 0xA9B40000u, + new[] + { + 0xA9B4000Fu, 0xA9B40017u, 0xA9B4002Fu, 0xA9B40016u, 0xA9B4001Eu, + 0xA9B40026u, 0xA9B40036u, 0xA9B4001Au, 0xA9B40022u, 0xA9B40032u, + 0xA9B40031u, 0xA9B40029u, + } + }, + { 0xA9B30000u, new[] { 0xA9B3003Cu } }, + { 0xAAB50000u, new[] { 0xAAB50002u } }, + }; + + [Theory] + [MemberData(nameof(TraceBuildingRosters))] + public void Every_building_retail_drew_exists_at_its_exact_position_cell( + uint landblockId, uint[] tracedBuildingIds) + { + using DatCollection dats = OpenDats(); + + List buildings = WalkWorldDatAdapter.BuildBuildings(dats, landblockId); + var roster = buildings.Select(b => b.PositionCellId).ToHashSet(); + + foreach (uint traced in tracedBuildingIds) + Assert.Contains(traced, roster); + } + + [Fact] + public void Traced_buildings_carry_portals_with_stab_lists_and_drawing_bsp_portals() + { + using DatCollection dats = OpenDats(); + + List buildings = WalkWorldDatAdapter.BuildBuildings(dats, 0xF4180000u); + WalkBuilding sanctuary = Assert.Single( + buildings, b => b.PositionCellId == 0xF418000Au); + + Assert.NotEmpty(sanctuary.Portals); + Assert.All(sanctuary.Portals, p => Assert.NotEmpty(p.StabList)); + // The look-in machinery needs PORT nodes in the drawing BSP. + Assert.NotNull(sanctuary.DrawingBsp); + Assert.True( + CountPortalRefs(sanctuary.DrawingBsp) > 0, + "the sanctuary building's drawing BSP must carry portal polygons"); + } + + [Fact] + public void The_traced_look_in_cells_build_with_portals_and_polygons() + { + using DatCollection dats = OpenDats(); + + // Cells named by the FW0 traces: the cathedral arrival root, the + // terrace cells, and the edge's extra look-in. + foreach (uint cellId in new[] { 0xF4180104u, 0xF4180106u, 0xF418010Fu }) + { + WalkCell? cell = WalkWorldDatAdapter.BuildCell(dats, cellId); + Assert.NotNull(cell); + Assert.NotEmpty(cell!.Portals); + Assert.Equal(cell.Portals.Length, cell.PortalPolygons.Length); + Assert.All(cell.PortalPolygons, p => Assert.True(p.Vertices.Length >= 3)); + Assert.NotEmpty(cell.StabList); + } + } + + private static int CountPortalRefs(WalkBspNode? node) + { + if (node is null) return 0; + int count = node.InPortals?.Length ?? 0; + return count + CountPortalRefs(node.PosNode) + CountPortalRefs(node.NegNode); + } +}