acdream/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs
Erik e23a589a6b feat(render) Campaign FW1: port the exact retail degrade selection
UpdateViewerDistance @0x0050e030 + get_degrade @0x0051e4b0 ported with
live-pinned globals: distance measured to the part SCALED SORT CENTER,
effective = max(0, dist - s_rDegradeDistance [live 100]), level = first
with effective < IdealDist (the live auto_update_deg_mul<=0 arm), else
the last level. WalkBuildingDegradeLevel carries the full authored
bands; the adapter fills sort centers; the replay context measures to
the transformed sort center. Sweep state after the rule: the level-0
building set is now correct; the residual divergence is the per-portal
side/clip gate (my arms punch a near-complement of retail two) - the
sweep driver carries the next instrument in its Skip note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 10:55:19 +02:00

290 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Environment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.App.Tests.Rendering.Walk;
using AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW1 — builds the walk's world model from real DAT data for the
/// CONFORMANCE HARNESS (it lives in the test assembly deliberately: raw
/// <c>DatCollection</c> access is fenced to the content-owner seam in
/// production by <c>RuntimeDatAccessArchitectureTests</c>; the FW3
/// production adapter will consume the seam instead). 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 &amp; 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
{
/// <summary>One built building plus its landblock-local placement.</summary>
public sealed record BuildingEntry(
WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform);
public static WalkCell? BuildCell(DatCollection dats, uint cellId)
=> BuildCell(dats, cellId, Vector3.Zero);
public static WalkCell? BuildCell(DatCollection dats, uint cellId, Vector3 blockOffset)
{
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();
}
Matrix4x4 worldTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation)
* Matrix4x4.CreateTranslation(
envCell.Position.Origin.X + blockOffset.X,
envCell.Position.Origin.Y + blockOffset.Y,
envCell.Position.Origin.Z + blockOffset.Z);
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
return new WalkCell
{
CellId = cellId,
Portals = portals,
PortalPolygons = polygons,
StabList = envCell.VisibleCells.Select(v => lbMask | v).ToArray(),
WorldTransform = worldTransform,
InverseWorldTransform = inverse,
};
}
public static Dictionary<uint, WalkCell> BuildInteriorCells(
DatCollection dats, uint landblockId)
=> BuildInteriorCells(dats, landblockId, Vector3.Zero, null);
public static Dictionary<uint, WalkCell> BuildInteriorCells(
DatCollection dats, uint landblockId, Vector3 blockOffset,
Dictionary<uint, WalkCell>? into)
{
uint lbMask = landblockId & 0xFFFF0000u;
Dictionary<uint, WalkCell> cells = into ?? new Dictionary<uint, WalkCell>();
for (uint low = 0x0100; low <= 0xFFFD; low++)
{
WalkCell? cell = BuildCell(dats, lbMask | low, blockOffset);
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, drawing-BSP portal trees, and
/// landblock-local placements.</summary>
public static List<BuildingEntry> BuildBuildings(DatCollection dats, uint landblockId)
{
uint lbMask = landblockId & 0xFFFF0000u;
var result = new List<BuildingEntry>();
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 = DecodeBuildingSide((ushort)portal.Flags),
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;
Vector3 sortCenter = Vector3.Zero;
var degradeLevels = new List<WalkBuildingDegradeLevel>();
if (dats.Get<GfxObj>(buildingInfo.ModelId) is GfxObj gfxObj)
{
bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
sortCenter = new Vector3(gfxObj.SortCenter.X, gfxObj.SortCenter.Y, gfxObj.SortCenter.Z);
if (gfxObj.DIDDegrade != 0
&& dats.Get<GfxObjDegradeInfo>(gfxObj.DIDDegrade)
is GfxObjDegradeInfo degradeInfo)
{
foreach (GfxObjInfo level in degradeInfo.Degrades)
{
WalkBspNode? levelBsp = null;
if (level.Id != 0
&& dats.Get<GfxObj>((uint)level.Id) is GfxObj levelGfx)
{
levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
}
degradeLevels.Add(new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp));
}
}
}
Matrix4x4 worldTransform =
Matrix4x4.CreateFromQuaternion(buildingInfo.Frame.Orientation)
* Matrix4x4.CreateTranslation(origin);
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
result.Add(new BuildingEntry(
new WalkBuilding
{
PositionCellId = positionCellId,
Portals = portals,
DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter,
},
worldTransform,
inverse));
}
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);
}
/// <summary>Adjudication toggle (2026-08-30): the GfxObj portal-poly
/// plane winding vs the cell convention is under test — the street
/// fixture shows retail punching far fewer look-ins than the first-3
/// cross convention admits.</summary>
public static bool FlipGfxPolygonPlanes;
/// <summary>Adjudication toggle: the BuildingPortal side-flag decode.
/// 0 = (Flags &amp; 0x2), 1 = inverted 0x2, 2 = (Flags &amp; 0x1),
/// 3 = inverted 0x1.</summary>
public static int BuildingSideMode;
private static int DecodeBuildingSide(ushort flags) => BuildingSideMode switch
{
0 => (flags & 0x2) != 0 ? 1 : 0,
1 => (flags & 0x2) != 0 ? 0 : 1,
2 => (flags & 0x1) != 0 ? 1 : 0,
_ => (flags & 0x1) != 0 ? 0 : 1,
};
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;
}
WalkPolygon? built = 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);
if (built is not null && FlipGfxPolygonPlanes)
built.Plane = new WalkPlane(-built.Plane.Normal, -built.Plane.D);
return built;
}
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])),
};
}
}