feat(render) Campaign FW1: the DAT adapter + ground-truth roster conformance
WalkWorldDatAdapter builds WalkCell/WalkBuilding from real DATs: cell portals with the PortalSide flag bit and signed OtherPortalId, portal polygons + planes via the production formula, stab lists, building position cells (24 m landcell math), building portals with stab lists, and the drawing-BSP -> WalkBspNode conversion incl. PORT-node PortalRef polygons (the previously missing consumers the 2026-08-30 survey named). Lane=InstalledDat conformance: every BLD id the FW0 oracle traces saw retail draw (26 buildings over 7 landblocks) resolves in the adapter roster at its EXACT position cell; the traced look-in cells build with portals/polygons/stabs; the sanctuary BSP carries portal refs. 9/9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4b401a08ed
commit
17c57bbd1d
2 changed files with 323 additions and 0 deletions
211
src/AcDream.App/Rendering/Walk/WalkWorldDatAdapter.cs
Normal file
211
src/AcDream.App/Rendering/Walk/WalkWorldDatAdapter.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>PortalRef{PolyId, PortalIndex}</c> 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):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>portal_side = <c>(Flags & 0x2) != 0</c> (PortalFlags.PortalSide),
|
||||||
|
/// matching <c>EnvCellLandblockBuild</c>'s production decode.</item>
|
||||||
|
/// <item>Portal-polygon planes use the first-three-vertices cross with
|
||||||
|
/// <c>d = −dot(N, p0)</c> — the same formula the production visibility
|
||||||
|
/// cell build has always used against retail's InitCell side test.</item>
|
||||||
|
/// <item>A building's event id is its containing outdoor landcell:
|
||||||
|
/// low word = cellX·8 + cellY + 1 at 24 m pitch.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class WalkWorldDatAdapter
|
||||||
|
{
|
||||||
|
public static WalkCell? BuildCell(DatCollection dats, uint cellId)
|
||||||
|
{
|
||||||
|
if (dats.Get<EnvCell>(cellId) is not EnvCell envCell)
|
||||||
|
return null;
|
||||||
|
if (dats.Get<Environment>(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<uint, WalkCell> BuildInteriorCells(
|
||||||
|
DatCollection dats, uint landblockId)
|
||||||
|
{
|
||||||
|
uint lbMask = landblockId & 0xFFFF0000u;
|
||||||
|
var cells = new Dictionary<uint, WalkCell>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Buildings of one landblock, with their walk event ids
|
||||||
|
/// (containing landcell), portal records, and drawing-BSP portal trees.</summary>
|
||||||
|
public static List<WalkBuilding> BuildBuildings(DatCollection dats, uint landblockId)
|
||||||
|
{
|
||||||
|
uint lbMask = landblockId & 0xFFFF0000u;
|
||||||
|
var result = new List<WalkBuilding>();
|
||||||
|
if (dats.Get<LandBlockInfo>(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<GfxObj>(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<WalkPortalRef>(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<short> vertexIds, Func<short, Vector3?> 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])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<uint, uint[]> 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<WalkBuilding> 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<WalkBuilding> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue