feat(render) Campaign FW3.1: production walk world data behind the seam
The retail frame walk's world model now materializes from production landblock-build owners through the legal IDatReaderWriter seam, with zero frame wiring (FW3.2 roots the frame): - WalkCellFactory: WalkCell built in the SAME pass as LoadedCell (EnvCellLandblockBuild.BuildVisibilityCell) from the raw portal Flags/polygons/planes/stab lists already parsed there; stored as LoadedCell.Walk, committed atomically with the cell. The fixture-pinned decodes (inverse-0x2 portal side, 0xFFFF->0xFFFFFFFF exit widening) live here. - WalkBuildingFactory + WalkBuildingRegistry: the production WalkBuilding build (drawing BSP with PORT nodes, degrade ladder, portal sides/stab lists, sort center, model frame) from the SAME LandBlockInfo the streaming build already fetches, under the factory's existing DAT lock - closing the gap where BuildingLoader drops every walk field at load. - WalkLandscapeAssembler: the retail 51x51 viewer-centred grid (mid_radius 25) fed incrementally from landblock publish/retire; per-block z-slab (heightTable[max]+200 / [min]-1) computed worker-side in LandblockBuildFactory from the heights already in hand. O(1) SetViewer on same-block frames. - WalkProductionFrameContext: the walk's frame contexts over CellVisibility + WalkBuildingRegistry with a generic inverse-view-projection ray caster (rays feed cross products only - scale-free) and the znear=0.1 CY plane. - Publication: LandblockRenderPublisher owns both walk registries, publishing in the same AdvanceCompleteOne step as BuildingRegistry and retiring in RemoveBuildingRegistry - same commit, same retirement, no new ticket stage. Conformance: ALL TEN oracle fixtures replay identically through the PRODUCTION builders (WalkProductionWorldConformanceTests) - same signatures as the test adapter, first run. Known gap documented for FW3.2: far-tier landblocks carry no EnvCell transaction, so their z-slab never reaches the assembler. Suites: full Release build 0 warnings; Walk lane 186/1 skip; hermetic 6,738/0 (+24); RuntimeDatAccessArchitectureTests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b95850defe
commit
b10ad662b0
14 changed files with 1711 additions and 2 deletions
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
|
@ -114,6 +115,21 @@ public sealed class LoadedCell
|
||||||
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
|
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsOutdoorNode;
|
public bool IsOutdoorNode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1: the retail frame walk's model of this cell (portal
|
||||||
|
/// side/exact-match decode, portal polygons + planes, stab list,
|
||||||
|
/// UNLIFTED transform), built atomically alongside this
|
||||||
|
/// <see cref="LoadedCell"/> by
|
||||||
|
/// <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c> — see
|
||||||
|
/// <c>WalkCellFactory.FromParsed</c>. Null only for hand-built test
|
||||||
|
/// fixtures that construct a <see cref="LoadedCell"/> directly instead of
|
||||||
|
/// through the streaming build. <c>WalkProductionFrameContext.GetVisible</c>
|
||||||
|
/// is the walk's sole read of this field — the committed registry
|
||||||
|
/// (<see cref="CellVisibility.TryGetCell"/>) is the walk's only cell
|
||||||
|
/// source; its dead BFS role is unrelated.
|
||||||
|
/// </summary>
|
||||||
|
public WalkCell? Walk { get; internal set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
191
src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs
Normal file
191
src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Content;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1 — production <see cref="WalkBuilding"/> construction,
|
||||||
|
/// ported from the FW1 conformance harness
|
||||||
|
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildBuildings
|
||||||
|
/// /ConvertDrawingBsp/BuildGfxPolygon) to the legal
|
||||||
|
/// <see cref="IDatReaderWriter"/> seam. This closes the FW3.1 gap: today's
|
||||||
|
/// <c>Wb.BuildingLoader.AddBuilding</c> (update thread) reads
|
||||||
|
/// <c>BuildingInfo.Portals</c> only for BFS seeding and drops ModelId,
|
||||||
|
/// Frame, portal Flags/StabList, and the drawing BSP the frame walk needs —
|
||||||
|
/// this factory reads the SAME <c>BuildingInfo</c> plus the GfxObj/
|
||||||
|
/// GfxObjDegradeInfo chain, on the worker thread inside
|
||||||
|
/// <c>LandblockBuildFactory</c> where <see cref="IDatReaderWriter"/> access
|
||||||
|
/// is legal (<c>BuildingLoader</c> itself never touches DATs — it runs
|
||||||
|
/// update-thread, after the streaming build already completed).
|
||||||
|
///
|
||||||
|
/// Adjudication toggles the FW1 harness carried as public mutable fields for
|
||||||
|
/// sweep testing are PINNED here to their winning arms (2026-08-30 data-pin,
|
||||||
|
/// verified against the ten oracle traces):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>GfxObj portal-polygon planes are NOT flipped (the plain
|
||||||
|
/// first-three-vertices cross survives every building-portal fixture).</item>
|
||||||
|
/// <item>Building portal_side is the INVERSE of the cell decode's 0x2 bit
|
||||||
|
/// (<c>WalkWorldDatAdapter.BuildingSideMode == 1</c>'s winning arm).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class WalkBuildingFactory
|
||||||
|
{
|
||||||
|
/// <summary>One built <see cref="WalkBuilding"/> plus its landblock-local
|
||||||
|
/// placement transform (<paramref name="WorldTransform"/> already has the
|
||||||
|
/// caller's block offset baked in — see <see cref="Build"/>).</summary>
|
||||||
|
public sealed record Entry(
|
||||||
|
WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform);
|
||||||
|
|
||||||
|
/// <summary>Builds every building of one landblock. <paramref name="lbOffset"/>
|
||||||
|
/// is the SAME landblock-local world offset every other production
|
||||||
|
/// builder in this streaming job uses (<c>LandblockBuildFactory</c>'s
|
||||||
|
/// <c>lbOffset</c>/<c>worldOffset</c> convention) — baked directly into
|
||||||
|
/// each building's world transform, which is mathematically identical to
|
||||||
|
/// the FW1 harness's two-step compose (build landblock-local, then
|
||||||
|
/// post-multiply the block offset translation): translations commute,
|
||||||
|
/// so <c>R·T(origin)·T(lbOffset) == R·T(origin+lbOffset)</c>.</summary>
|
||||||
|
public static List<Entry> Build(
|
||||||
|
IDatReaderWriter dats, uint landblockId,
|
||||||
|
IReadOnlyList<BuildingInfo>? buildingInfos, Vector3 lbOffset)
|
||||||
|
{
|
||||||
|
var result = new List<Entry>();
|
||||||
|
if (buildingInfos is null)
|
||||||
|
return result;
|
||||||
|
uint lbMask = landblockId & 0xFFFF0000u;
|
||||||
|
|
||||||
|
foreach (BuildingInfo buildingInfo in buildingInfos)
|
||||||
|
{
|
||||||
|
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 + lbOffset);
|
||||||
|
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
|
||||||
|
result.Add(new Entry(
|
||||||
|
new WalkBuilding
|
||||||
|
{
|
||||||
|
PositionCellId = positionCellId,
|
||||||
|
Portals = portals,
|
||||||
|
DrawingBsp = bsp,
|
||||||
|
DegradeLevels = degradeLevels.ToArray(),
|
||||||
|
SortCenter = sortCenter,
|
||||||
|
},
|
||||||
|
worldTransform,
|
||||||
|
inverse));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DecodeBuildingSide(ushort flags) => (flags & 0x2) != 0 ? 0 : 1;
|
||||||
|
|
||||||
|
private 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 == BSPNodeType.Leaf,
|
||||||
|
PosNode = ConvertDrawingBsp(gfxObj, node.PosNode),
|
||||||
|
NegNode = ConvertDrawingBsp(gfxObj, node.NegNode),
|
||||||
|
};
|
||||||
|
if (node.Type == 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? 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])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs
Normal file
75
src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1 — the walk's per-landblock <see cref="WalkBuilding"/>
|
||||||
|
/// registry: the production sibling of
|
||||||
|
/// <see cref="AcDream.App.Rendering.Wb.BuildingRegistry"/>. Publish/retire
|
||||||
|
/// mirror that registry's landblock lifecycle exactly — both are committed
|
||||||
|
/// together in <c>LandblockRenderPublisher.AdvanceCompleteOne</c>'s
|
||||||
|
/// <c>BuildingRegistryCommitted</c> step and retired together from
|
||||||
|
/// <c>LandblockRenderPublisher.RemoveBuildingRegistry</c> — because a walk
|
||||||
|
/// building and its BFS-derived <c>Wb.Building</c> counterpart come from the
|
||||||
|
/// SAME <c>BuildingInfo</c> array at the SAME landblock commit.
|
||||||
|
///
|
||||||
|
/// The reverse index (<see cref="TryGetEntry"/>) exists because
|
||||||
|
/// <see cref="WalkProductionFrameContext"/> resolves a building's placement
|
||||||
|
/// by REFERENCE during the portal pass — <c>ViewpointInBuilding</c>,
|
||||||
|
/// <c>ViewerDistanceTo</c>, and <c>ClipBuildingPolygon</c> are called many
|
||||||
|
/// times per building per frame once FW3.2 wires the walk into the render
|
||||||
|
/// loop, so this must be O(1), not a per-landblock scan.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkBuildingRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, IReadOnlyList<WalkBuildingFactory.Entry>> _byLandblock = new();
|
||||||
|
private readonly Dictionary<WalkBuilding, WalkBuildingFactory.Entry> _byBuilding = new();
|
||||||
|
|
||||||
|
/// <summary>Atomically replaces one landblock's complete building set.
|
||||||
|
/// Mirrors <c>Wb.BuildingRegistry</c>'s "no partial landblock" commit
|
||||||
|
/// discipline — <paramref name="entries"/> is the immutable, fully-built
|
||||||
|
/// list carried by the streaming worker's
|
||||||
|
/// <c>Wb.EnvCellLandblockBuild.WalkBuildings</c>.</summary>
|
||||||
|
public void Publish(uint landblockId, IReadOnlyList<WalkBuildingFactory.Entry> entries)
|
||||||
|
{
|
||||||
|
uint key = landblockId & 0xFFFF0000u;
|
||||||
|
if (_byLandblock.TryGetValue(key, out IReadOnlyList<WalkBuildingFactory.Entry>? previous))
|
||||||
|
{
|
||||||
|
foreach (WalkBuildingFactory.Entry entry in previous)
|
||||||
|
_byBuilding.Remove(entry.Building);
|
||||||
|
}
|
||||||
|
_byLandblock[key] = entries;
|
||||||
|
foreach (WalkBuildingFactory.Entry entry in entries)
|
||||||
|
_byBuilding[entry.Building] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes every building of one landblock. Safe to call on a
|
||||||
|
/// landblock that never published (no-op).</summary>
|
||||||
|
public void Retire(uint landblockId)
|
||||||
|
{
|
||||||
|
uint key = landblockId & 0xFFFF0000u;
|
||||||
|
if (_byLandblock.Remove(key, out IReadOnlyList<WalkBuildingFactory.Entry>? previous))
|
||||||
|
{
|
||||||
|
foreach (WalkBuildingFactory.Entry entry in previous)
|
||||||
|
_byBuilding.Remove(entry.Building);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The buildings of one landblock, or empty when none are
|
||||||
|
/// published (unloaded, far-tier, or no <c>BuildingInfo</c> entries).</summary>
|
||||||
|
public IReadOnlyList<WalkBuildingFactory.Entry> GetBuildings(uint landblockId) =>
|
||||||
|
_byLandblock.TryGetValue(landblockId & 0xFFFF0000u, out IReadOnlyList<WalkBuildingFactory.Entry>? list)
|
||||||
|
? list
|
||||||
|
: Array.Empty<WalkBuildingFactory.Entry>();
|
||||||
|
|
||||||
|
/// <summary>O(1) reverse lookup: this building's committed placement.
|
||||||
|
/// False means the building is not (or no longer) committed — the walk
|
||||||
|
/// must treat that as a hard desync, never a silent skip (the FW
|
||||||
|
/// fail-loud rule).</summary>
|
||||||
|
public bool TryGetEntry(
|
||||||
|
WalkBuilding building, [MaybeNullWhen(false)] out WalkBuildingFactory.Entry entry) =>
|
||||||
|
_byBuilding.TryGetValue(building, out entry);
|
||||||
|
|
||||||
|
/// <summary>Number of landblocks with committed buildings (diagnostics).</summary>
|
||||||
|
public int LandblockCount => _byLandblock.Count;
|
||||||
|
}
|
||||||
167
src/AcDream.App/Rendering/Walk/WalkCellFactory.cs
Normal file
167
src/AcDream.App/Rendering/Walk/WalkCellFactory.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Content;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using Environment = DatReaderWriter.DBObjs.Environment;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1 — production <see cref="WalkCell"/> construction, ported
|
||||||
|
/// from the FW1 conformance harness
|
||||||
|
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildCell)
|
||||||
|
/// to the legal <see cref="IDatReaderWriter"/> seam
|
||||||
|
/// (<c>RuntimeDatAccessArchitectureTests</c> forbids raw
|
||||||
|
/// <c>DatCollection</c> anywhere in this assembly outside
|
||||||
|
/// <c>RuntimeDatCollectionFactory</c>/<c>DatCollectionAdapter</c>).
|
||||||
|
///
|
||||||
|
/// <see cref="FromParsed"/> is the shared core: it takes an ALREADY-FETCHED
|
||||||
|
/// <see cref="EnvCell"/>/<see cref="CellStruct"/> pair and the production
|
||||||
|
/// UNLIFTED cell transform. <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c>
|
||||||
|
/// (src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs) calls this
|
||||||
|
/// directly — it already has both parsed and the transform in hand from its
|
||||||
|
/// own <see cref="LoadedCell"/> construction, so no extra DAT read is spent.
|
||||||
|
/// <see cref="BuildCell"/> is the standalone DAT-reading convenience for
|
||||||
|
/// callers that only have a cell id (the walk landscape's stab-list
|
||||||
|
/// look-in cells, and the FW3 conformance harness).
|
||||||
|
///
|
||||||
|
/// Conventions (fixture-pinned; the ten oracle traces are the referee — see
|
||||||
|
/// <c>docs/research/2026-08-30-fw-walk-oracle/</c>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>portal_side = <c>(Flags & 0x2) != 0 ? 0 : 1</c> — the INVERSE of
|
||||||
|
/// the bit, matching <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c>'s
|
||||||
|
/// existing <c>PortalClipPlane.InsideSide</c> decode
|
||||||
|
/// (<c>(Flags & 0x2) == 0 ? 1 : 0</c> — the same formula).</item>
|
||||||
|
/// <item>Exit-portal sentinel widening: dat <c>0xFFFF</c> →
|
||||||
|
/// <c>0xFFFFFFFF</c> (<see cref="WalkCellPortal.OtherCellId"/> is a full
|
||||||
|
/// cell id; the dat field is a 16-bit local id).</item>
|
||||||
|
/// <item>Portal-polygon plane: first-three-vertices cross,
|
||||||
|
/// <c>d = −dot(N, p0)</c> — the same formula
|
||||||
|
/// <c>BuildVisibilityCell</c>'s <c>PortalClipPlane</c> already uses.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class WalkCellFactory
|
||||||
|
{
|
||||||
|
/// <summary>DAT-reading entry point: fetches EnvCell/Environment/CellStruct
|
||||||
|
/// and builds their landblock-local (<paramref name="blockOffset"/>
|
||||||
|
/// added) UNLIFTED transform before delegating to <see cref="FromParsed"/>.
|
||||||
|
/// Returns null when the cell, its environment, or its cell structure
|
||||||
|
/// cannot be resolved (unregistered/degenerate cell — same as the FW1
|
||||||
|
/// test adapter).</summary>
|
||||||
|
public static WalkCell? BuildCell(IDatReaderWriter 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 FromParsed(cellId, envCell, cellStruct, worldTransform, inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a <see cref="WalkCell"/> from an already-fetched
|
||||||
|
/// EnvCell/CellStruct pair and the production cell transform. No DAT
|
||||||
|
/// access — safe to call from a context that already holds the reader
|
||||||
|
/// lock for an unrelated read.</summary>
|
||||||
|
public static WalkCell FromParsed(
|
||||||
|
uint cellId, EnvCell envCell, CellStruct cellStruct,
|
||||||
|
Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform)
|
||||||
|
{
|
||||||
|
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 ? 0 : 1,
|
||||||
|
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(),
|
||||||
|
WorldTransform = worldTransform,
|
||||||
|
InverseWorldTransform = inverseWorldTransform,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every interior cell of one landblock, bounded by
|
||||||
|
/// <c>LandBlockInfo.NumCells</c> (the production bound —
|
||||||
|
/// <c>LandblockBuildFactory.BuildInteriorEntitiesForStreaming</c> uses
|
||||||
|
/// the same count; the FW1 test adapter instead scans until the first
|
||||||
|
/// miss, which is equivalent for well-formed installed DATs).</summary>
|
||||||
|
public static Dictionary<uint, WalkCell> BuildInteriorCells(
|
||||||
|
IDatReaderWriter dats, uint landblockId, Vector3 blockOffset,
|
||||||
|
Dictionary<uint, WalkCell>? into = null)
|
||||||
|
{
|
||||||
|
uint lbMask = landblockId & 0xFFFF0000u;
|
||||||
|
Dictionary<uint, WalkCell> cells = into ?? new Dictionary<uint, WalkCell>();
|
||||||
|
if (dats.Get<LandBlockInfo>(lbMask | 0xFFFEu) is not LandBlockInfo info)
|
||||||
|
return cells;
|
||||||
|
|
||||||
|
uint firstCellId = lbMask | 0x0100u;
|
||||||
|
for (uint offset = 0; offset < info.NumCells; offset++)
|
||||||
|
{
|
||||||
|
WalkCell? cell = BuildCell(dats, firstCellId + offset, blockOffset);
|
||||||
|
if (cell is not null)
|
||||||
|
cells[cell.CellId] = cell;
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
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? 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])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs
Normal file
191
src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1 — the production sibling of the FW1 conformance harness's
|
||||||
|
/// <c>WalkLandscapeDatBuilder</c>
|
||||||
|
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs): owns
|
||||||
|
/// retail's viewer-centred block grid (LScape <c>mid_radius</c> = 25 → a
|
||||||
|
/// 51×51 window, recon 2026-08-30) and its LOD ring pyramid
|
||||||
|
/// (<see cref="SideCellCountForRing"/>).
|
||||||
|
///
|
||||||
|
/// Unlike the test builder — which re-scans the whole grid from a
|
||||||
|
/// <c>DatCollection</c> on every call — this class is fed INCREMENTALLY from
|
||||||
|
/// landblock commit/retire (<see cref="PublishLandblock"/>/
|
||||||
|
/// <see cref="RetireLandblock"/>), the same lifecycle
|
||||||
|
/// <c>Wb.BuildingRegistry</c> and <c>CellVisibility</c> already follow. Each
|
||||||
|
/// landblock's z-slab and building placements are computed ONCE, on the
|
||||||
|
/// streaming worker thread, at landblock-build time
|
||||||
|
/// (<c>LandblockBuildFactory</c>/<c>WalkBuildingFactory</c>) — publishing
|
||||||
|
/// here is a pure dictionary write, no DAT or GfxObj work.
|
||||||
|
///
|
||||||
|
/// <see cref="SetViewer"/> recentres the grid on the camera's block, porting
|
||||||
|
/// the test builder's viewer-cell recompute; it goes further by also
|
||||||
|
/// reflowing the 51×51 window when the camera crosses into a different
|
||||||
|
/// landblock (the test builder never needs this — every moving fixture stays
|
||||||
|
/// within its anchor block). Reflow only touches already-published data (a
|
||||||
|
/// dictionary lookup per slot); no DAT access ever happens here.
|
||||||
|
///
|
||||||
|
/// No frame currently calls <see cref="SetViewer"/> or reads
|
||||||
|
/// <see cref="Landscape"/> — FW3.2 wires <c>RetailFrameWalk</c> into the
|
||||||
|
/// render loop. This class exists now so that wiring is additive, and so the
|
||||||
|
/// FW3.1 conformance gate can exercise the SAME assembly code path
|
||||||
|
/// production will drive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkLandscapeAssembler
|
||||||
|
{
|
||||||
|
/// <summary>Retail LScape <c>mid_radius</c> (recon 2026-08-30) — NOT
|
||||||
|
/// acdream's streaming radius (two-tier N1/N2), which is smaller. Blocks
|
||||||
|
/// outside the streamed window simply stay unpublished (null slots).</summary>
|
||||||
|
public const int MidRadius = 25;
|
||||||
|
|
||||||
|
public const int GridWidth = MidRadius * 2 + 1;
|
||||||
|
|
||||||
|
private sealed class BlockData
|
||||||
|
{
|
||||||
|
public required float MaxZ;
|
||||||
|
public required float MinZ;
|
||||||
|
public required IReadOnlyList<WalkBuildingFactory.Entry> Buildings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<(int X, int Y), BlockData> _blocks = new();
|
||||||
|
private int _viewerBlockX = int.MinValue;
|
||||||
|
private int _viewerBlockY = int.MinValue;
|
||||||
|
|
||||||
|
public WalkLandscape Landscape { get; } = new()
|
||||||
|
{
|
||||||
|
MidWidth = GridWidth,
|
||||||
|
Blocks = new WalkLandBlock?[GridWidth * GridWidth],
|
||||||
|
ViewerBlockX = MidRadius,
|
||||||
|
ViewerBlockY = MidRadius,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Landblock commit
|
||||||
|
/// (<c>LandblockRenderPublisher.AdvanceCompleteOne</c>'s
|
||||||
|
/// <c>BuildingRegistryCommitted</c> step): register/replace this
|
||||||
|
/// landblock's z-slab and building placements. If the landblock falls
|
||||||
|
/// within the CURRENT window, the visible slot refreshes immediately.</summary>
|
||||||
|
public void PublishLandblock(
|
||||||
|
uint landblockId, float maxZ, float minZ,
|
||||||
|
IReadOnlyList<WalkBuildingFactory.Entry> buildings)
|
||||||
|
{
|
||||||
|
(int bx, int by) = BlockCoords(landblockId);
|
||||||
|
_blocks[(bx, by)] = new BlockData { MaxZ = maxZ, MinZ = minZ, Buildings = buildings };
|
||||||
|
RefreshSlotIfWindowed(bx, by);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Landblock retirement (<c>LandblockRenderPublisher
|
||||||
|
/// .RemoveBuildingRegistry</c>): drop this landblock's data. Safe to call
|
||||||
|
/// on a landblock that never published (no-op).</summary>
|
||||||
|
public void RetireLandblock(uint landblockId)
|
||||||
|
{
|
||||||
|
(int bx, int by) = BlockCoords(landblockId);
|
||||||
|
_blocks.Remove((bx, by));
|
||||||
|
RefreshSlotIfWindowed(bx, by);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recentres the grid on the camera's block and updates the
|
||||||
|
/// sub-block cell offsets (retail's viewer recentre —
|
||||||
|
/// <c>WalkLandscapeDatBuilder.SetViewer</c>'s port). When the camera's
|
||||||
|
/// block hasn't changed since the last call, this only updates
|
||||||
|
/// <see cref="WalkLandscape.ViewerCellX"/>/<see cref="WalkLandscape.ViewerCellY"/>
|
||||||
|
/// — zero allocation, matching the test builder's lightweight path. A
|
||||||
|
/// block crossing additionally reflows the 51×51 window from already-
|
||||||
|
/// published data (a bounded, allocation-free dictionary-lookup pass —
|
||||||
|
/// no DAT/GfxObj work, which already happened at publish time).</summary>
|
||||||
|
public void SetViewer(uint cameraCellId, Vector3 cameraOrigin)
|
||||||
|
{
|
||||||
|
int cameraBlockX = (int)(cameraCellId >> 24);
|
||||||
|
int cameraBlockY = (int)((cameraCellId >> 16) & 0xFF);
|
||||||
|
if (cameraBlockX != _viewerBlockX || cameraBlockY != _viewerBlockY)
|
||||||
|
{
|
||||||
|
_viewerBlockX = cameraBlockX;
|
||||||
|
_viewerBlockY = cameraBlockY;
|
||||||
|
RebuildWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint low = cameraCellId & 0xFFFFu;
|
||||||
|
if (low >= 1 && low <= 0x40)
|
||||||
|
{
|
||||||
|
int cellIndex = (int)low - 1;
|
||||||
|
Landscape.ViewerCellX = cellIndex / 8;
|
||||||
|
Landscape.ViewerCellY = cellIndex % 8;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Interior camera: derive the outside-projected landcell from
|
||||||
|
// the camera origin, matching the test builder's fallback
|
||||||
|
// (Position::get_outside_cell_id via SmartBox::RenderNormalMode's
|
||||||
|
// seen_outside arm).
|
||||||
|
Landscape.ViewerCellX = Math.Clamp((int)MathF.Floor(cameraOrigin.X / 24f), 0, 7);
|
||||||
|
Landscape.ViewerCellY = Math.Clamp((int)MathF.Floor(cameraOrigin.Y / 24f), 0, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>ring <= 1 ? 8 : ring == 2 ? 4 : ring <= 4 ? 2 : 1</c>
|
||||||
|
/// — the live-observed resolution pyramid (recon 2026-08-30): 8×8 in the
|
||||||
|
/// 3×3 core, 4×4 at ring 2, 2×2 at rings 3–4, 1×1 beyond. Buildings only
|
||||||
|
/// attach at full resolution (ring ≤ 1) — the traces show no BLD beyond
|
||||||
|
/// ±1 block.</summary>
|
||||||
|
internal static int SideCellCountForRing(int ring)
|
||||||
|
=> ring <= 1 ? 8 : ring == 2 ? 4 : ring <= 4 ? 2 : 1;
|
||||||
|
|
||||||
|
internal static int RingOf(int gridX, int gridY)
|
||||||
|
=> Math.Max(Math.Abs(gridX - MidRadius), Math.Abs(gridY - MidRadius));
|
||||||
|
|
||||||
|
private void RebuildWindow()
|
||||||
|
{
|
||||||
|
for (int gx = 0; gx < GridWidth; gx++)
|
||||||
|
{
|
||||||
|
for (int gy = 0; gy < GridWidth; gy++)
|
||||||
|
{
|
||||||
|
int bx = _viewerBlockX + gx - MidRadius;
|
||||||
|
int by = _viewerBlockY + gy - MidRadius;
|
||||||
|
Landscape.Blocks[gx * GridWidth + gy] = BuildSlot(bx, by, gx, gy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshSlotIfWindowed(int bx, int by)
|
||||||
|
{
|
||||||
|
if (_viewerBlockX == int.MinValue)
|
||||||
|
return; // SetViewer has never run — no window to refresh yet.
|
||||||
|
int gx = bx - _viewerBlockX + MidRadius;
|
||||||
|
int gy = by - _viewerBlockY + MidRadius;
|
||||||
|
if (gx < 0 || gx >= GridWidth || gy < 0 || gy >= GridWidth)
|
||||||
|
return;
|
||||||
|
Landscape.Blocks[gx * GridWidth + gy] = BuildSlot(bx, by, gx, gy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WalkLandBlock? BuildSlot(int bx, int by, int gx, int gy)
|
||||||
|
{
|
||||||
|
if (bx < 0 || bx > 0xFF || by < 0 || by > 0xFF)
|
||||||
|
return null;
|
||||||
|
if (!_blocks.TryGetValue((bx, by), out BlockData? data))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int sideCellCount = SideCellCountForRing(RingOf(gx, gy));
|
||||||
|
var block = new WalkLandBlock
|
||||||
|
{
|
||||||
|
SideCellCount = sideCellCount,
|
||||||
|
MaxZ = data.MaxZ,
|
||||||
|
MinZ = data.MinZ,
|
||||||
|
};
|
||||||
|
block.EnsureCellArrays();
|
||||||
|
|
||||||
|
if (sideCellCount == 8)
|
||||||
|
{
|
||||||
|
foreach (WalkBuildingFactory.Entry entry in data.Buildings)
|
||||||
|
{
|
||||||
|
int cellIndex = (int)(entry.Building.PositionCellId & 0xFFFFu) - 1;
|
||||||
|
if (cellIndex >= 0 && cellIndex < 64)
|
||||||
|
block.CellBuildings[cellIndex] = entry.Building;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int X, int Y) BlockCoords(uint landblockId)
|
||||||
|
=> ((int)((landblockId >> 24) & 0xFFu), (int)((landblockId >> 16) & 0xFFu));
|
||||||
|
}
|
||||||
174
src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs
Normal file
174
src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1 — the production <see cref="IWalkFrameContext"/> /
|
||||||
|
/// <see cref="IRetailFrameWalkContext"/> / <see cref="IWalkBuildingFrameContext"/>
|
||||||
|
/// implementation: cells resolve through the committed
|
||||||
|
/// <see cref="CellVisibility"/> registry (<c>LoadedCell.Walk</c>, per the FW
|
||||||
|
/// binding rule that the walk consumes ONLY the committed registry —
|
||||||
|
/// <c>CellVisibility.TryGetCell</c>, never a synthetic/dead-code path);
|
||||||
|
/// buildings resolve through <see cref="WalkBuildingRegistry"/>; the camera
|
||||||
|
/// pose, projection, and viewport are supplied by the caller.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT coupled to any concrete camera type — FW3.2 will supply
|
||||||
|
/// live values from <c>WorldCameraFrame</c>; wiring that in is additive.
|
||||||
|
/// The ray caster is a GENERIC inverse-view-projection unprojection, not the
|
||||||
|
/// capture client's exact <c>Render::xinvscale</c>/<c>tx</c>/<c>vdst</c>
|
||||||
|
/// constants the FW1 conformance harness's <c>WalkTraceReplayContext</c>
|
||||||
|
/// uses — those are FIXTURE PINS specific to the 1024×720 capture client,
|
||||||
|
/// not production values. This is safe because
|
||||||
|
/// <see cref="WalkCopyView.Append"/> only ever CROSS-PRODUCTS these rays to
|
||||||
|
/// build view-edge planes: a uniform scale or additive offset along a ray
|
||||||
|
/// cancels out of every cross product it feeds, so any two points along the
|
||||||
|
/// true eye ray (near/far unprojection) are observably equivalent to
|
||||||
|
/// retail's exact construction for this contract.
|
||||||
|
///
|
||||||
|
/// One instance is a per-frame value (like <c>WalkTraceReplayContext</c>):
|
||||||
|
/// construct fresh each frame with that frame's camera pose.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext
|
||||||
|
{
|
||||||
|
/// <summary><c>Render::znear</c> @0x0081ec84 / <c>set_vdst</c> @0x0054b240.</summary>
|
||||||
|
public const float ZNear = 0.1f;
|
||||||
|
|
||||||
|
private sealed class InverseViewProjectionRayCaster : IWalkRayCaster
|
||||||
|
{
|
||||||
|
private readonly Matrix4x4 _inverseViewProjection;
|
||||||
|
private readonly float _viewportWidth;
|
||||||
|
private readonly float _viewportHeight;
|
||||||
|
|
||||||
|
public InverseViewProjectionRayCaster(
|
||||||
|
Matrix4x4 viewProjection, float viewportWidth, float viewportHeight)
|
||||||
|
{
|
||||||
|
if (!Matrix4x4.Invert(viewProjection, out _inverseViewProjection))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The walk's view-projection matrix must be invertible.",
|
||||||
|
nameof(viewProjection));
|
||||||
|
}
|
||||||
|
_viewportWidth = viewportWidth;
|
||||||
|
_viewportHeight = viewportHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Screen space: origin top-left, +Y down — matching
|
||||||
|
/// <see cref="WalkScreenClip.TransformToScreen"/> and
|
||||||
|
/// <see cref="WalkCopyView.AppendFullViewportQuad"/>'s quad
|
||||||
|
/// winding.</summary>
|
||||||
|
public Vector3 RayThrough(float screenX, float screenY)
|
||||||
|
{
|
||||||
|
float ndcX = screenX / _viewportWidth * 2f - 1f;
|
||||||
|
float ndcY = 1f - screenY / _viewportHeight * 2f;
|
||||||
|
Vector4 near = Vector4.Transform(
|
||||||
|
new Vector4(ndcX, ndcY, 0f, 1f), _inverseViewProjection);
|
||||||
|
Vector4 far = Vector4.Transform(
|
||||||
|
new Vector4(ndcX, ndcY, 1f, 1f), _inverseViewProjection);
|
||||||
|
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
|
||||||
|
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
|
||||||
|
return farWorld - nearWorld;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly CellVisibility _cells;
|
||||||
|
private readonly WalkBuildingRegistry _buildings;
|
||||||
|
private readonly Matrix4x4 _viewProjection;
|
||||||
|
private readonly IWalkRayCaster _rays;
|
||||||
|
|
||||||
|
private Vector2[] _activeViewVerts = new Vector2[32];
|
||||||
|
private int _activeViewVertCount;
|
||||||
|
|
||||||
|
public WalkProductionFrameContext(
|
||||||
|
CellVisibility cells,
|
||||||
|
WalkBuildingRegistry buildings,
|
||||||
|
Vector3 worldViewpoint,
|
||||||
|
Vector3 forward,
|
||||||
|
Matrix4x4 viewProjection,
|
||||||
|
float viewportWidth,
|
||||||
|
float viewportHeight)
|
||||||
|
{
|
||||||
|
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
|
||||||
|
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||||||
|
WorldViewpoint = worldViewpoint;
|
||||||
|
_viewProjection = viewProjection;
|
||||||
|
ViewportWidth = viewportWidth;
|
||||||
|
ViewportHeight = viewportHeight;
|
||||||
|
_rays = new InverseViewProjectionRayCaster(viewProjection, viewportWidth, viewportHeight);
|
||||||
|
// The retail CY near plane: N = forward, d = -dot(eye, forward) - znear.
|
||||||
|
CyPlane = new WalkPlane(forward, -Vector3.Dot(worldViewpoint, forward) - ZNear);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 WorldViewpoint { get; }
|
||||||
|
public float ViewportWidth { get; }
|
||||||
|
public float ViewportHeight { get; }
|
||||||
|
public WalkPlane CyPlane { get; }
|
||||||
|
public IWalkRayCaster Rays => _rays;
|
||||||
|
public IWalkFrameContext CellContext => this;
|
||||||
|
|
||||||
|
public Vector3 ViewpointIn(WalkCell cell)
|
||||||
|
=> Vector3.Transform(WorldViewpoint, cell.InverseWorldTransform);
|
||||||
|
|
||||||
|
public Matrix4x4 ObjectToClip(WalkCell cell) => cell.WorldTransform * _viewProjection;
|
||||||
|
|
||||||
|
/// <summary>Resolves through the committed <see cref="CellVisibility"/>
|
||||||
|
/// registry only — a missing/uncommitted cell returns null (retail's
|
||||||
|
/// portal-skip behavior; the FW binding rule requires any such miss to
|
||||||
|
/// be diagnostically counted under a flag rather than silently swallowed
|
||||||
|
/// once a caller wires one up — this seam does not itself log, matching
|
||||||
|
/// <see cref="IWalkFrameContext.GetVisible"/>'s documented contract).</summary>
|
||||||
|
public WalkCell? GetVisible(uint cellId)
|
||||||
|
=> _cells.TryGetCell(cellId, out LoadedCell? cell) ? cell?.Walk : null;
|
||||||
|
|
||||||
|
public void SetActiveView(WalkPortalView views, int index)
|
||||||
|
{
|
||||||
|
WalkViewPoly poly = views.View.Polys[index];
|
||||||
|
if (_activeViewVerts.Length < poly.VertexCount)
|
||||||
|
_activeViewVerts = new Vector2[poly.VertexCount];
|
||||||
|
for (int k = 0; k < poly.VertexCount; k++)
|
||||||
|
_activeViewVerts[k] = views.View.Vertices[poly.VertexIndex + k].Point;
|
||||||
|
_activeViewVertCount = poly.VertexCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 ViewpointInBuilding(WalkBuilding building)
|
||||||
|
=> Vector3.Transform(WorldViewpoint, GetEntry(building).InverseWorldTransform);
|
||||||
|
|
||||||
|
/// <summary><c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030: the
|
||||||
|
/// distance to the part's SCALED sort center, not the position origin.</summary>
|
||||||
|
public float ViewerDistanceTo(WalkBuilding building)
|
||||||
|
{
|
||||||
|
WalkBuildingFactory.Entry entry = GetEntry(building);
|
||||||
|
return Vector3.Distance(
|
||||||
|
WorldViewpoint, Vector3.Transform(building.SortCenter, entry.WorldTransform));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ClipBuildingPolygon(
|
||||||
|
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
|
||||||
|
{
|
||||||
|
Matrix4x4 objectToClip = GetEntry(building).WorldTransform * _viewProjection;
|
||||||
|
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
|
||||||
|
for (int i = 0; i < polygon.Vertices.Length; i++)
|
||||||
|
{
|
||||||
|
projected[i] = WalkScreenClip.TransformToScreen(
|
||||||
|
polygon.Vertices[i], objectToClip, ViewportWidth, ViewportHeight);
|
||||||
|
}
|
||||||
|
if (side != 0)
|
||||||
|
projected.Reverse();
|
||||||
|
return WalkScreenClip.ClipAgainstView(
|
||||||
|
projected, _activeViewVerts.AsSpan(0, _activeViewVertCount), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WalkBuildingFactory.Entry GetEntry(WalkBuilding building)
|
||||||
|
{
|
||||||
|
if (!_buildings.TryGetEntry(building, out var entry))
|
||||||
|
{
|
||||||
|
// Fail loud (the PV3 post-mortem rule): a building the walk is
|
||||||
|
// actively placing MUST be committed in the same registry the
|
||||||
|
// walk was handed — a miss here is a walk/registry desync, never
|
||||||
|
// a silently-skipped building.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"WalkProductionFrameContext was asked to place a WalkBuilding " +
|
||||||
|
"that is not committed in its WalkBuildingRegistry.");
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
using AcDream.Core.Rendering.Wb;
|
using AcDream.Core.Rendering.Wb;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
|
|
@ -34,22 +35,48 @@ public sealed class EnvCellLandblockBuild
|
||||||
public EnvCellLandblockBuild(
|
public EnvCellLandblockBuild(
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
IEnumerable<LoadedCell> visibilityCells,
|
IEnumerable<LoadedCell> visibilityCells,
|
||||||
IEnumerable<EnvCellShellPlacement> shells)
|
IEnumerable<EnvCellShellPlacement> shells,
|
||||||
|
IEnumerable<WalkBuildingFactory.Entry>? walkBuildings = null,
|
||||||
|
float walkMaxZ = 0f,
|
||||||
|
float walkMinZ = 0f)
|
||||||
{
|
{
|
||||||
LandblockId = landblockId;
|
LandblockId = landblockId;
|
||||||
VisibilityCells = visibilityCells.ToImmutableArray();
|
VisibilityCells = visibilityCells.ToImmutableArray();
|
||||||
Shells = shells.ToImmutableArray();
|
Shells = shells.ToImmutableArray();
|
||||||
|
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
|
||||||
|
WalkMaxZ = walkMaxZ;
|
||||||
|
WalkMinZ = walkMinZ;
|
||||||
|
|
||||||
uint expectedPrefix = landblockId & 0xFFFF0000u;
|
uint expectedPrefix = landblockId & 0xFFFF0000u;
|
||||||
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
|
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||||
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
|
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
|
||||||
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
|
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||||
throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
|
throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
|
||||||
|
if (WalkBuildings.Any(entry => (entry.Building.PositionCellId & 0xFFFF0000u) != expectedPrefix))
|
||||||
|
throw new ArgumentException("A walk building belongs to a different landblock.", nameof(walkBuildings));
|
||||||
}
|
}
|
||||||
|
|
||||||
public uint LandblockId { get; }
|
public uint LandblockId { get; }
|
||||||
public ImmutableArray<LoadedCell> VisibilityCells { get; }
|
public ImmutableArray<LoadedCell> VisibilityCells { get; }
|
||||||
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
|
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: this landblock's walk building placements
|
||||||
|
/// (<c>WalkBuildingFactory.Build</c>, computed worker-side alongside
|
||||||
|
/// <see cref="VisibilityCells"/>). Committed atomically with the rest of
|
||||||
|
/// this transaction — see <c>WalkBuildingRegistry.Publish</c> at
|
||||||
|
/// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary>
|
||||||
|
public ImmutableArray<WalkBuildingFactory.Entry> WalkBuildings { get; }
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: this landblock's retail z-slab
|
||||||
|
/// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block
|
||||||
|
/// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>).
|
||||||
|
/// Computed once, worker-side, from the SAME heightmap bytes + height
|
||||||
|
/// table the terrain mesh build already reads — no extra DAT access.</summary>
|
||||||
|
public float WalkMaxZ { get; }
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: this landblock's retail z-slab
|
||||||
|
/// (<c>heightTable[minByte] − 1</c>). See <see cref="WalkMaxZ"/>.</summary>
|
||||||
|
public float WalkMinZ { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -62,6 +89,9 @@ public sealed class EnvCellLandblockBuildBuilder
|
||||||
private readonly uint _landblockId;
|
private readonly uint _landblockId;
|
||||||
private readonly List<LoadedCell> _visibilityCells = new();
|
private readonly List<LoadedCell> _visibilityCells = new();
|
||||||
private readonly List<EnvCellShellPlacement> _shells = new();
|
private readonly List<EnvCellShellPlacement> _shells = new();
|
||||||
|
private readonly List<WalkBuildingFactory.Entry> _walkBuildings = new();
|
||||||
|
private float _walkMaxZ;
|
||||||
|
private float _walkMinZ;
|
||||||
private bool _built;
|
private bool _built;
|
||||||
|
|
||||||
public EnvCellLandblockBuildBuilder(uint landblockId)
|
public EnvCellLandblockBuildBuilder(uint landblockId)
|
||||||
|
|
@ -69,6 +99,31 @@ public sealed class EnvCellLandblockBuildBuilder
|
||||||
_landblockId = landblockId;
|
_landblockId = landblockId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: registers this landblock's walk building
|
||||||
|
/// placements (<c>WalkBuildingFactory.Build</c>'s output). Additive to
|
||||||
|
/// the existing cell/shell accumulation — called once per landblock from
|
||||||
|
/// <c>LandblockBuildFactory.BuildInteriorEntitiesForStreaming</c>, where
|
||||||
|
/// <c>LandBlockInfo</c> is already in hand.</summary>
|
||||||
|
public void AddWalkBuildings(IEnumerable<WalkBuildingFactory.Entry> entries)
|
||||||
|
{
|
||||||
|
if (_built)
|
||||||
|
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||||
|
_walkBuildings.AddRange(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: registers this landblock's retail z-slab
|
||||||
|
/// (<see cref="EnvCellLandblockBuild.WalkMaxZ"/>/<see cref="EnvCellLandblockBuild.WalkMinZ"/>).
|
||||||
|
/// Independent of interior-cell/building presence — every near-tier
|
||||||
|
/// landblock has outdoor terrain heights, so
|
||||||
|
/// <c>LandblockBuildFactory.BuildLocked</c> calls this unconditionally.</summary>
|
||||||
|
public void SetWalkZSlab(float maxZ, float minZ)
|
||||||
|
{
|
||||||
|
if (_built)
|
||||||
|
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||||
|
_walkMaxZ = maxZ;
|
||||||
|
_walkMinZ = minZ;
|
||||||
|
}
|
||||||
|
|
||||||
public void AddCell(
|
public void AddCell(
|
||||||
uint envCellId,
|
uint envCellId,
|
||||||
EnvCell envCell,
|
EnvCell envCell,
|
||||||
|
|
@ -116,7 +171,8 @@ public sealed class EnvCellLandblockBuildBuilder
|
||||||
if (_built)
|
if (_built)
|
||||||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||||
_built = true;
|
_built = true;
|
||||||
return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells);
|
return new EnvCellLandblockBuild(
|
||||||
|
_landblockId, _visibilityCells, _shells, _walkBuildings, _walkMaxZ, _walkMinZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -290,6 +346,20 @@ public sealed class EnvCellLandblockBuildBuilder
|
||||||
PortalPolygons = portalPolygons,
|
PortalPolygons = portalPolygons,
|
||||||
VisibleCells = visibleCells,
|
VisibleCells = visibleCells,
|
||||||
SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
|
SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
|
||||||
|
// Campaign FW3.1: the walk's model of this cell, built from the
|
||||||
|
// SAME EnvCell/CellStruct/transform this LoadedCell was just
|
||||||
|
// built from — WalkCellFactory.FromParsed does its own portal
|
||||||
|
// pass (independent of the clipPlanes/portalPolygons above; the
|
||||||
|
// two consumers decode the same dat fields for different needs)
|
||||||
|
// so it stays a direct, auditable port of the FW1 test adapter
|
||||||
|
// rather than reshaping LoadedCell's own list layout around it.
|
||||||
|
// cellTransform/inverse here are UNLIFTED (this method's
|
||||||
|
// cellOrigin/cellTransform params are the caller's
|
||||||
|
// physicsCellOrigin/physicsCellTransform — see
|
||||||
|
// LandblockBuildFactory.BuildInteriorEntitiesForStreaming, which
|
||||||
|
// keeps the +0.02 m ShellDrawLiftZ out of this transform) —
|
||||||
|
// exactly what the walk wants.
|
||||||
|
Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,15 @@ public sealed class LandblockBuildFactory
|
||||||
worldOffset,
|
worldOffset,
|
||||||
_heightTable));
|
_heightTable));
|
||||||
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
||||||
|
// Campaign FW3.1: the walk landscape's per-block z-slab
|
||||||
|
// (CLandBlock::calc_lighting-adjacent unpack @0x0052f1d0). Every
|
||||||
|
// near-tier landblock has outdoor terrain heights regardless of
|
||||||
|
// whether it has interior cells or buildings, so this is
|
||||||
|
// unconditional — unlike WalkBuildings (added below, inside
|
||||||
|
// BuildInteriorEntitiesForStreaming, where LandBlockInfo is already
|
||||||
|
// in hand).
|
||||||
|
(float walkMaxZ, float walkMinZ) = ComputeWalkZSlab(baseLoaded.Heightmap.Height);
|
||||||
|
envCellBuild.SetWalkZSlab(walkMaxZ, walkMinZ);
|
||||||
merged.AddRange(BuildInteriorEntitiesForStreaming(
|
merged.AddRange(BuildInteriorEntitiesForStreaming(
|
||||||
landblockId,
|
landblockId,
|
||||||
lbX,
|
lbX,
|
||||||
|
|
@ -448,6 +457,17 @@ public sealed class LandblockBuildFactory
|
||||||
(lbY - origin.CenterY) * 192f,
|
(lbY - origin.CenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
|
// Campaign FW3.1: the walk's building placements (portals + drawing
|
||||||
|
// BSP + degrade ladder + sort center), read from the SAME
|
||||||
|
// LandBlockInfo.Buildings this method already fetched above — no
|
||||||
|
// extra DAT read. This closes the gap where Wb.BuildingLoader
|
||||||
|
// .AddBuilding (update thread, no DAT access) keeps only
|
||||||
|
// ModelId/Frame/portal-flags/stab-lists for its own BFS seeding and
|
||||||
|
// drops everything the frame walk needs.
|
||||||
|
envCellBuild.AddWalkBuildings(
|
||||||
|
AcDream.App.Rendering.Walk.WalkBuildingFactory.Build(
|
||||||
|
_dats, landblockId, lbInfo.Buildings, lbOffset));
|
||||||
|
|
||||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||||
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
||||||
// landblock stabs (0xC0000000+, ids from LandblockLoader).
|
// landblock stabs (0xC0000000+, ids from LandblockLoader).
|
||||||
|
|
@ -649,6 +669,24 @@ public sealed class LandblockBuildFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: retail's per-block z-slab
|
||||||
|
/// (<c>WalkLandscapeDatBuilder</c>'s port target —
|
||||||
|
/// <c>CLandBlock::calc_lighting</c>-adjacent unpack @0x0052f1d0):
|
||||||
|
/// <c>max_zval = heightTable[maxByte] + 200</c>,
|
||||||
|
/// <c>min_zval = heightTable[minByte] - 1</c>, over the landblock's own
|
||||||
|
/// 81-byte heightmap. Uses <see cref="_heightTable"/> — already a
|
||||||
|
/// constructor field, so this needs no additional DAT read.</summary>
|
||||||
|
private (float MaxZ, float MinZ) ComputeWalkZSlab(byte[] heights)
|
||||||
|
{
|
||||||
|
byte maxByte = 0, minByte = 255;
|
||||||
|
foreach (byte h in heights)
|
||||||
|
{
|
||||||
|
if (h > maxByte) maxByte = h;
|
||||||
|
if (h < minByte) minByte = h;
|
||||||
|
}
|
||||||
|
return (_heightTable[maxByte] + 200f, _heightTable[minByte] - 1f);
|
||||||
|
}
|
||||||
|
|
||||||
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
||||||
{
|
{
|
||||||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.Rendering.Wb;
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
using AcDream.Core.Terrain;
|
using AcDream.Core.Terrain;
|
||||||
|
|
||||||
namespace AcDream.App.Streaming;
|
namespace AcDream.App.Streaming;
|
||||||
|
|
@ -102,6 +103,17 @@ public sealed class LandblockRenderPublisher
|
||||||
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
|
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
|
||||||
private readonly Action<uint>? _removeEnvCells;
|
private readonly Action<uint>? _removeEnvCells;
|
||||||
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
|
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
|
||||||
|
// Campaign FW3.1: the walk's production world-data siblings of
|
||||||
|
// _buildingRegistries. Owned here (not injected) exactly like
|
||||||
|
// _buildingRegistries — no caller constructs a LandblockRenderPublisher
|
||||||
|
// with pre-existing walk state. Committed/retired at the same points as
|
||||||
|
// the building registry (see AdvanceCompleteOne / RemoveBuildingRegistry
|
||||||
|
// below) because both come from the same landblock's BuildingInfo array
|
||||||
|
// at the same commit. No frame reads these yet (FW3.2 wires the walk
|
||||||
|
// into the render loop) — publication happens now so FW3.1's
|
||||||
|
// conformance gate exercises the production commit path.
|
||||||
|
private readonly WalkBuildingRegistry _walkBuildingRegistry = new();
|
||||||
|
private readonly WalkLandscapeAssembler _walkLandscape = new();
|
||||||
|
|
||||||
private long _beginCount;
|
private long _beginCount;
|
||||||
private long _completeCount;
|
private long _completeCount;
|
||||||
|
|
@ -148,6 +160,15 @@ public sealed class LandblockRenderPublisher
|
||||||
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
|
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
|
||||||
_buildingRegistries.Values;
|
_buildingRegistries.Values;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: the walk's production building placements,
|
||||||
|
/// keyed by landblock, committed alongside <see cref="BuildingRegistries"/>.</summary>
|
||||||
|
public WalkBuildingRegistry WalkBuildings => _walkBuildingRegistry;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1: the walk's production viewer-centred
|
||||||
|
/// landscape grid, fed from the same landblock commits as
|
||||||
|
/// <see cref="WalkBuildings"/>.</summary>
|
||||||
|
public WalkLandscapeAssembler WalkLandscape => _walkLandscape;
|
||||||
|
|
||||||
public LandblockRenderPublisherDiagnostics Diagnostics => new(
|
public LandblockRenderPublisherDiagnostics Diagnostics => new(
|
||||||
_beginCount,
|
_beginCount,
|
||||||
_completeCount,
|
_completeCount,
|
||||||
|
|
@ -339,6 +360,21 @@ public sealed class LandblockRenderPublisher
|
||||||
_buildingRegistries[registryKey] =
|
_buildingRegistries[registryKey] =
|
||||||
completedBuildings.Registry;
|
completedBuildings.Registry;
|
||||||
}
|
}
|
||||||
|
// Campaign FW3.1: publish this landblock's walk world data in
|
||||||
|
// the same step — build.EnvCells carries WalkBuildings/WalkMaxZ
|
||||||
|
// /WalkMinZ (computed worker-side by LandblockBuildFactory) for
|
||||||
|
// every near-tier build, even one with zero interior cells
|
||||||
|
// (the z-slab is unconditional; WalkBuildings is naturally empty
|
||||||
|
// when LandBlockInfo has none).
|
||||||
|
if (build.EnvCells is { } walkEnvCells)
|
||||||
|
{
|
||||||
|
_walkBuildingRegistry.Publish(landblockId, walkEnvCells.WalkBuildings);
|
||||||
|
_walkLandscape.PublishLandblock(
|
||||||
|
landblockId,
|
||||||
|
walkEnvCells.WalkMaxZ,
|
||||||
|
walkEnvCells.WalkMinZ,
|
||||||
|
walkEnvCells.WalkBuildings);
|
||||||
|
}
|
||||||
publication.BuildingRegistryCommitted = true;
|
publication.BuildingRegistryCommitted = true;
|
||||||
}
|
}
|
||||||
else if (publication.EnvCellPublication is { } envCellPublication
|
else if (publication.EnvCellPublication is { } envCellPublication
|
||||||
|
|
@ -397,6 +433,14 @@ public sealed class LandblockRenderPublisher
|
||||||
public void RemoveBuildingRegistry(uint landblockId)
|
public void RemoveBuildingRegistry(uint landblockId)
|
||||||
{
|
{
|
||||||
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
|
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
|
||||||
|
// Campaign FW3.1: retire the walk's siblings at the SAME retirement
|
||||||
|
// stage — they were committed together above, so they retire
|
||||||
|
// together (no new LandblockRetirementStage; folding into the
|
||||||
|
// existing BuildingRegistry stage keeps the retirement ticket state
|
||||||
|
// machine unchanged, which is the minimal/additive choice for a
|
||||||
|
// slice that does no frame wiring yet).
|
||||||
|
_walkBuildingRegistry.Retire(landblockId);
|
||||||
|
_walkLandscape.RetireLandblock(landblockId);
|
||||||
_buildingRegistryRemovalCount++;
|
_buildingRegistryRemovalCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1 — hermetic (no DAT) coverage of the walk's
|
||||||
|
/// production building registry: landblock-keyed publish/retire and the
|
||||||
|
/// O(1) reverse index <see cref="WalkProductionFrameContext"/> relies on.</summary>
|
||||||
|
public sealed class WalkBuildingRegistryTests
|
||||||
|
{
|
||||||
|
private static WalkBuildingFactory.Entry Entry(uint positionCellId) =>
|
||||||
|
new(new WalkBuilding { PositionCellId = positionCellId }, Matrix4x4.Identity, Matrix4x4.Identity);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_MakesBuildingsFindableByLandblockAndByReference()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
WalkBuildingFactory.Entry entry = Entry(0xA9B40001u);
|
||||||
|
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { entry });
|
||||||
|
|
||||||
|
Assert.Same(entry, Assert.Single(registry.GetBuildings(0xA9B40100u)));
|
||||||
|
Assert.True(registry.TryGetEntry(entry.Building, out var found));
|
||||||
|
Assert.Same(entry, found);
|
||||||
|
Assert.Equal(1, registry.LandblockCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_ReplacesThePreviousLandblockAndDropsItsReverseIndexEntries()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
WalkBuildingFactory.Entry first = Entry(0xA9B40001u);
|
||||||
|
WalkBuildingFactory.Entry second = Entry(0xA9B40002u);
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { first });
|
||||||
|
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { second });
|
||||||
|
|
||||||
|
Assert.Same(second, Assert.Single(registry.GetBuildings(0xA9B4FFFFu)));
|
||||||
|
Assert.False(registry.TryGetEntry(first.Building, out _));
|
||||||
|
Assert.True(registry.TryGetEntry(second.Building, out _));
|
||||||
|
Assert.Equal(1, registry.LandblockCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retire_RemovesTheLandblockAndItsReverseIndexEntries()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
WalkBuildingFactory.Entry entry = Entry(0xA9B40001u);
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { entry });
|
||||||
|
|
||||||
|
registry.Retire(0xA9B40100u); // any id sharing the landblock prefix
|
||||||
|
|
||||||
|
Assert.Empty(registry.GetBuildings(0xA9B4FFFFu));
|
||||||
|
Assert.False(registry.TryGetEntry(entry.Building, out _));
|
||||||
|
Assert.Equal(0, registry.LandblockCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retire_UnknownLandblockIsANoOp()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
|
||||||
|
registry.Retire(0xA9B4FFFFu);
|
||||||
|
|
||||||
|
Assert.Equal(0, registry.LandblockCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBuildings_UnpublishedLandblockReturnsEmpty()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
|
||||||
|
Assert.Empty(registry.GetBuildings(0xA9B4FFFFu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1 — hermetic (no DAT) coverage of the production
|
||||||
|
/// landscape assembler: publish/retire lifecycle, the LOD ring pyramid, and
|
||||||
|
/// the viewer-recentre grid reflow. The DAT-backed equivalence to the FW1
|
||||||
|
/// test builder is proven by <c>WalkProductionWorldConformanceTests</c>
|
||||||
|
/// (Lane=InstalledDat); this class covers the incremental publish/retire
|
||||||
|
/// machinery that harness doesn't exercise (it publishes and calls
|
||||||
|
/// SetViewer exactly once per fixture).</summary>
|
||||||
|
public sealed class WalkLandscapeAssemblerTests
|
||||||
|
{
|
||||||
|
private const uint LandblockId = 0xA9B4FFFFu; // block (0xA9, 0xB4)
|
||||||
|
private const uint CameraCellId = 0xA9B40001u; // same block, outdoor landcell 1
|
||||||
|
|
||||||
|
private static int GridIndex(int gx, int gy) => gx * WalkLandscapeAssembler.GridWidth + gy;
|
||||||
|
|
||||||
|
private static int CenterIndex() =>
|
||||||
|
GridIndex(WalkLandscapeAssembler.MidRadius, WalkLandscapeAssembler.MidRadius);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublishBeforeSetViewer_IsVisibleOnceSetViewerRuns()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
|
||||||
|
assembler.PublishLandblock(LandblockId, maxZ: 100f, minZ: -5f, Array.Empty<WalkBuildingFactory.Entry>());
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
|
||||||
|
WalkLandBlock? block = assembler.Landscape.Blocks[CenterIndex()];
|
||||||
|
Assert.NotNull(block);
|
||||||
|
Assert.Equal(8, block!.SideCellCount);
|
||||||
|
Assert.Equal(100f, block.MaxZ);
|
||||||
|
Assert.Equal(-5f, block.MinZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublishAfterSetViewer_RefreshesTheAlreadyWindowedSlotImmediately()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
|
||||||
|
assembler.PublishLandblock(LandblockId, maxZ: 42f, minZ: 3f, Array.Empty<WalkBuildingFactory.Entry>());
|
||||||
|
|
||||||
|
Assert.Equal(42f, assembler.Landscape.Blocks[CenterIndex()]!.MaxZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RingPyramid_FarBlockDegradesSideCellCountAndNeverAttachesBuildings()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
var building = new WalkBuildingFactory.Entry(
|
||||||
|
new WalkBuilding { PositionCellId = (LandblockId & 0xFFFF0000u) | 1u },
|
||||||
|
Matrix4x4.Identity, Matrix4x4.Identity);
|
||||||
|
assembler.PublishLandblock(LandblockId, 1f, 0f, new[] { building });
|
||||||
|
|
||||||
|
// Three blocks north of the camera's own block -> ring 3 -> SideCellCount 2.
|
||||||
|
const uint FarLandblockId = 0xA9B7FFFFu;
|
||||||
|
assembler.PublishLandblock(FarLandblockId, 1f, 0f, new[] { building });
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
|
||||||
|
WalkLandBlock center = assembler.Landscape.Blocks[CenterIndex()]!;
|
||||||
|
WalkLandBlock far = assembler.Landscape.Blocks[
|
||||||
|
GridIndex(WalkLandscapeAssembler.MidRadius, WalkLandscapeAssembler.MidRadius + 3)]!;
|
||||||
|
Assert.Equal(8, center.SideCellCount);
|
||||||
|
Assert.Contains(center.CellBuildings, b => b is not null);
|
||||||
|
Assert.Equal(2, far.SideCellCount);
|
||||||
|
Assert.All(far.CellBuildings, Assert.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetireLandblock_NullsTheWindowedSlot()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
assembler.PublishLandblock(LandblockId, 1f, 0f, Array.Empty<WalkBuildingFactory.Entry>());
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
|
||||||
|
assembler.RetireLandblock(LandblockId);
|
||||||
|
|
||||||
|
Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetireLandblock_UnpublishedLandblockIsANoOp()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
|
||||||
|
assembler.RetireLandblock(LandblockId);
|
||||||
|
|
||||||
|
Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetViewer_RecentresTheWindowWhenTheCameraCrossesIntoAnotherBlock()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
assembler.PublishLandblock(LandblockId, 7f, -1f, Array.Empty<WalkBuildingFactory.Entry>());
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
|
||||||
|
// Move the camera one block east; the published landblock should now
|
||||||
|
// sit one grid slot WEST of center instead of at center.
|
||||||
|
assembler.SetViewer(0xAAB40001u, Vector3.Zero);
|
||||||
|
|
||||||
|
Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
WalkLandBlock? shifted = assembler.Landscape.Blocks[
|
||||||
|
GridIndex(WalkLandscapeAssembler.MidRadius - 1, WalkLandscapeAssembler.MidRadius)];
|
||||||
|
Assert.NotNull(shifted);
|
||||||
|
Assert.Equal(7f, shifted!.MaxZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetViewer_LandcellIndexDerivesViewerCellFromLowWord()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
|
||||||
|
// Low word 10 -> landcell index 9 -> (9/8, 9%8) = (1, 1).
|
||||||
|
assembler.SetViewer(0xA9B4000Au, Vector3.Zero);
|
||||||
|
|
||||||
|
Assert.Equal(1, assembler.Landscape.ViewerCellX);
|
||||||
|
Assert.Equal(1, assembler.Landscape.ViewerCellY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetViewer_InteriorCameraDerivesViewerCellFromOrigin()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
|
||||||
|
assembler.SetViewer(0xA9B40105u, new Vector3(50f, 74f, 0f));
|
||||||
|
|
||||||
|
Assert.Equal(2, assembler.Landscape.ViewerCellX); // floor(50 / 24) = 2
|
||||||
|
Assert.Equal(3, assembler.Landscape.ViewerCellY); // floor(74 / 24) = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetViewer_SameBlockRepeatCallDoesNotClearAlreadyPublishedSlots()
|
||||||
|
{
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
assembler.PublishLandblock(LandblockId, 1f, 0f, Array.Empty<WalkBuildingFactory.Entry>());
|
||||||
|
assembler.SetViewer(CameraCellId, Vector3.Zero);
|
||||||
|
|
||||||
|
assembler.SetViewer(CameraCellId, new Vector3(5f, 5f, 0f));
|
||||||
|
|
||||||
|
Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.1 — hermetic (no DAT) coverage of the production
|
||||||
|
/// frame context: cell resolution through <see cref="CellVisibility"/>,
|
||||||
|
/// building resolution through <see cref="WalkBuildingRegistry"/>, and the
|
||||||
|
/// CyPlane/ray-cast wiring. This proves the SEAM (does the context read the
|
||||||
|
/// right registries the right way); the ten-fixture conformance gate proves
|
||||||
|
/// the WORLD DATA those registries are fed is correct.</summary>
|
||||||
|
public sealed class WalkProductionFrameContextTests
|
||||||
|
{
|
||||||
|
private static Matrix4x4 SimpleViewProjection() =>
|
||||||
|
Matrix4x4.CreateLookAt(Vector3.Zero, Vector3.UnitY, Vector3.UnitZ)
|
||||||
|
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetVisible_ResolvesThroughTheCommittedCellVisibilityRegistry()
|
||||||
|
{
|
||||||
|
var cellVisibility = new CellVisibility();
|
||||||
|
var walkCell = new WalkCell { CellId = 0xA9B40100u };
|
||||||
|
var loaded = new LoadedCell { CellId = 0xA9B40100u, Walk = walkCell };
|
||||||
|
cellVisibility.CommitLandblock(0xA9B4FFFFu, new[] { loaded });
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
cellVisibility, new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Same(walkCell, ctx.GetVisible(0xA9B40100u));
|
||||||
|
Assert.Null(ctx.GetVisible(0xA9B40101u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetVisible_ReturnsNullWhenTheCommittedCellHasNoWalkModel()
|
||||||
|
{
|
||||||
|
// A hand-built LoadedCell that never went through
|
||||||
|
// EnvCellLandblockBuildBuilder.BuildVisibilityCell (test-only
|
||||||
|
// shortcut some existing fixtures take) — Walk stays null.
|
||||||
|
var cellVisibility = new CellVisibility();
|
||||||
|
var loaded = new LoadedCell { CellId = 0xA9B40100u };
|
||||||
|
cellVisibility.CommitLandblock(0xA9B4FFFFu, new[] { loaded });
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
cellVisibility, new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Null(ctx.GetVisible(0xA9B40100u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObjectToClipAndViewpointIn_UseTheCellsOwnTransforms()
|
||||||
|
{
|
||||||
|
var cell = new WalkCell
|
||||||
|
{
|
||||||
|
CellId = 1,
|
||||||
|
WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f),
|
||||||
|
InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f),
|
||||||
|
};
|
||||||
|
Matrix4x4 vp = SimpleViewProjection();
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), new WalkBuildingRegistry(), new Vector3(10f, 0f, 0f), Vector3.UnitY,
|
||||||
|
vp, 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Equal(cell.WorldTransform * vp, ctx.ObjectToClip(cell));
|
||||||
|
Assert.Equal(Vector3.Zero, ctx.ViewpointIn(cell));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewpointInBuilding_ResolvesThroughWalkBuildingRegistry()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
var building = new WalkBuilding { PositionCellId = 1 };
|
||||||
|
Matrix4x4 world = Matrix4x4.CreateTranslation(5f, 0f, 0f);
|
||||||
|
Matrix4x4.Invert(world, out Matrix4x4 inverse);
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { new WalkBuildingFactory.Entry(building, world, inverse) });
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), registry, new Vector3(5f, 0f, 0f), Vector3.UnitY,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Equal(Vector3.Zero, ctx.ViewpointInBuilding(building));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewpointInBuilding_ThrowsWhenTheBuildingIsNotCommitted()
|
||||||
|
{
|
||||||
|
// Fail loud (the PV3 post-mortem rule): a walk/registry desync must
|
||||||
|
// never resolve to a silently-skipped building.
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
var unregistered = new WalkBuilding { PositionCellId = 1 };
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => ctx.ViewpointInBuilding(unregistered));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewerDistanceTo_MeasuresToTheBuildingsTransformedSortCenter()
|
||||||
|
{
|
||||||
|
var registry = new WalkBuildingRegistry();
|
||||||
|
var building = new WalkBuilding { PositionCellId = 1, SortCenter = new Vector3(0f, 3f, 0f) };
|
||||||
|
Matrix4x4 world = Matrix4x4.CreateTranslation(0f, 10f, 0f);
|
||||||
|
Matrix4x4.Invert(world, out Matrix4x4 inverse);
|
||||||
|
registry.Publish(0xA9B4FFFFu, new[] { new WalkBuildingFactory.Entry(building, world, inverse) });
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), registry, Vector3.Zero, Vector3.UnitY,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Equal(13f, ctx.ViewerDistanceTo(building));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CyPlane_MatchesTheRetailNearPlaneFormula()
|
||||||
|
{
|
||||||
|
Vector3 forward = Vector3.UnitY;
|
||||||
|
var eye = new Vector3(0f, 5f, 0f);
|
||||||
|
var ctx = new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), new WalkBuildingRegistry(), eye, forward,
|
||||||
|
SimpleViewProjection(), 1024f, 768f);
|
||||||
|
|
||||||
|
Assert.Equal(forward, ctx.CyPlane.Normal);
|
||||||
|
Assert.Equal(-Vector3.Dot(eye, forward) - WalkProductionFrameContext.ZNear, ctx.CyPlane.D);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_RejectsANonInvertibleViewProjection()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => new WalkProductionFrameContext(
|
||||||
|
new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
|
||||||
|
default, 1024f, 768f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Tests.Rendering;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
using AcDream.Content;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering.Walk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW3.1's load-bearing deliverable: the ten pose-stamped oracle
|
||||||
|
/// fixtures (docs/research/2026-08-30-fw-walk-oracle/), replayed through the
|
||||||
|
/// PRODUCTION world-data builders
|
||||||
|
/// (<see cref="WalkCellFactory"/>/<see cref="WalkBuildingFactory"/>
|
||||||
|
/// /<see cref="WalkLandscapeAssembler"/> — the same code
|
||||||
|
/// <c>EnvCellLandblockBuildBuilder</c>/<c>LandblockBuildFactory</c> run at
|
||||||
|
/// real landblock-build time, consuming ONLY the legal
|
||||||
|
/// <see cref="IDatReaderWriter"/> seam), must reproduce the identical walk
|
||||||
|
/// output <see cref="WalkTraceConformanceTests"/> already proves against the
|
||||||
|
/// FW1 test adapter (<c>WalkWorldDatAdapter</c>/<c>WalkLandscapeDatBuilder</c>).
|
||||||
|
///
|
||||||
|
/// This class deliberately does NOT touch <see cref="WalkTraceConformanceTests"/>
|
||||||
|
/// (frozen — must stay green untouched) but reuses its driver/signature
|
||||||
|
/// helpers verbatim: <see cref="WalkTraceReplayContext"/>,
|
||||||
|
/// <see cref="WalkOracleTrace"/>, <see cref="WalkOraclePose"/>,
|
||||||
|
/// <see cref="WalkOracleFrame"/>. <see cref="WalkTraceReplayContext.Buildings"/>
|
||||||
|
/// is typed against the TEST adapter's <c>WalkWorldDatAdapter.BuildingEntry</c>
|
||||||
|
/// record — structurally identical to <see cref="WalkBuildingFactory.Entry"/>
|
||||||
|
/// (same three fields) — so <see cref="BuildProductionWorld"/> below adapts
|
||||||
|
/// one into the other rather than touching the shared context type.
|
||||||
|
///
|
||||||
|
/// <see cref="BuildProductionWorld"/> mirrors the FW1 harness's
|
||||||
|
/// <c>WalkLandscapeDatBuilder.Build</c> loop structure and ring math
|
||||||
|
/// EXACTLY (51×51 grid, <c>WalkLandscapeAssembler.MidRadius</c> = 25,
|
||||||
|
/// buildings/stab-cells only at full resolution), but every DATA-BUILDING
|
||||||
|
/// step below it — cells, buildings, the drawing BSP, z-slab — calls the
|
||||||
|
/// PRODUCTION functions. That is the whole conformance claim: the grid/ring
|
||||||
|
/// assembly is proven once (FW1's existing gate, via
|
||||||
|
/// <see cref="WalkLandscapeAssembler"/>'s own port of that same math); this
|
||||||
|
/// class proves the DATA those slots are filled with is identical to the
|
||||||
|
/// FW1 test adapter's.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Lane", "InstalledDat")]
|
||||||
|
public sealed class WalkProductionWorldConformanceTests
|
||||||
|
{
|
||||||
|
private sealed class Recorder : IWalkEventSink
|
||||||
|
{
|
||||||
|
public readonly List<WalkEvent> Events = new();
|
||||||
|
public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the walk's world data (landscape + interior cells +
|
||||||
|
/// buildings) for a camera pose through the PRODUCTION builders, in the
|
||||||
|
/// exact shape <see cref="WalkTraceReplayContext"/> expects. Mirrors
|
||||||
|
/// <c>WalkLandscapeDatBuilder.Build</c>'s grid loop; every cell/building
|
||||||
|
/// constructed inside it comes from <see cref="WalkCellFactory"/> /
|
||||||
|
/// <see cref="WalkBuildingFactory"/>.</summary>
|
||||||
|
private static (WalkLandscapeAssembler Assembler, Dictionary<uint, WalkCell> Cells,
|
||||||
|
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> Buildings)
|
||||||
|
BuildProductionWorld(IDatReaderWriter dats, uint cameraCellId, Vector3 cameraOrigin)
|
||||||
|
{
|
||||||
|
int cameraBlockX = (int)(cameraCellId >> 24);
|
||||||
|
int cameraBlockY = (int)((cameraCellId >> 16) & 0xFF);
|
||||||
|
var assembler = new WalkLandscapeAssembler();
|
||||||
|
var cells = new Dictionary<uint, WalkCell>();
|
||||||
|
var buildings = new Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry>();
|
||||||
|
Region region = (Region)dats.Get<Region>(0x13000000u)!;
|
||||||
|
float[] heightTable = region.LandDefs.LandHeightTable;
|
||||||
|
|
||||||
|
for (int gx = 0; gx < WalkLandscapeAssembler.GridWidth; gx++)
|
||||||
|
{
|
||||||
|
for (int gy = 0; gy < WalkLandscapeAssembler.GridWidth; gy++)
|
||||||
|
{
|
||||||
|
int blockX = cameraBlockX + gx - WalkLandscapeAssembler.MidRadius;
|
||||||
|
int blockY = cameraBlockY + gy - WalkLandscapeAssembler.MidRadius;
|
||||||
|
if (blockX < 0 || blockX > 0xFF || blockY < 0 || blockY > 0xFF)
|
||||||
|
continue;
|
||||||
|
uint landblockId = (uint)((blockX << 24) | (blockY << 16));
|
||||||
|
if (dats.Get<LandBlock>(landblockId | 0xFFFFu) is not LandBlock landBlock)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
byte maxByte = 0, minByte = 255;
|
||||||
|
foreach (byte h in landBlock.Height)
|
||||||
|
{
|
||||||
|
if (h > maxByte) maxByte = h;
|
||||||
|
if (h < minByte) minByte = h;
|
||||||
|
}
|
||||||
|
float maxZ = heightTable[maxByte] + 200f;
|
||||||
|
float minZ = heightTable[minByte] - 1f;
|
||||||
|
|
||||||
|
var blockOffset = new Vector3(
|
||||||
|
(gx - WalkLandscapeAssembler.MidRadius) * WalkLandscape.BlockLength,
|
||||||
|
(gy - WalkLandscapeAssembler.MidRadius) * WalkLandscape.BlockLength,
|
||||||
|
0f);
|
||||||
|
|
||||||
|
var blockBuildings = new List<WalkBuildingFactory.Entry>();
|
||||||
|
if (WalkLandscapeAssembler.SideCellCountForRing(WalkLandscapeAssembler.RingOf(gx, gy)) == 8)
|
||||||
|
{
|
||||||
|
LandBlockInfo? info = dats.Get<LandBlockInfo>(landblockId | 0xFFFEu);
|
||||||
|
blockBuildings = WalkBuildingFactory.Build(dats, landblockId, info?.Buildings, blockOffset);
|
||||||
|
foreach (WalkBuildingFactory.Entry entry in blockBuildings)
|
||||||
|
{
|
||||||
|
buildings[entry.Building] = new WalkWorldDatAdapter.BuildingEntry(
|
||||||
|
entry.Building, entry.WorldTransform, entry.InverseWorldTransform);
|
||||||
|
|
||||||
|
// Retail's loaded-interior rule (CLandBlock::init_buildings
|
||||||
|
// @0052fd80 -> add_to_stablist -> grab_visible_cells): a
|
||||||
|
// full-res block loads exactly its buildings' portal stab
|
||||||
|
// cells, matching WalkLandscapeDatBuilder's harness scope.
|
||||||
|
foreach (WalkBldPortal portal in entry.Building.Portals)
|
||||||
|
{
|
||||||
|
if (portal.OtherCellId != 0xFFFFFFFFu && !cells.ContainsKey(portal.OtherCellId))
|
||||||
|
{
|
||||||
|
WalkCell? c = WalkCellFactory.BuildCell(dats, portal.OtherCellId, blockOffset);
|
||||||
|
if (c is not null) cells[c.CellId] = c;
|
||||||
|
}
|
||||||
|
foreach (uint stab in portal.StabList)
|
||||||
|
{
|
||||||
|
if (cells.ContainsKey(stab)) continue;
|
||||||
|
WalkCell? c = WalkCellFactory.BuildCell(dats, stab, blockOffset);
|
||||||
|
if (c is not null) cells[c.CellId] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assembler.PublishLandblock(landblockId, maxZ, minZ, blockBuildings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assembler.SetViewer(cameraCellId, cameraOrigin);
|
||||||
|
return (assembler, cells, buildings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Street_outdoor_first_frame_diff()
|
||||||
|
{
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load("posed/holtburg-street-outdoor");
|
||||||
|
Assert.NotEmpty(frames);
|
||||||
|
using DatCollection dats = OpenDats();
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
WalkOracleFrame frame = frames[1];
|
||||||
|
Assert.NotNull(frame.Pose);
|
||||||
|
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
|
||||||
|
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
|
||||||
|
BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
|
||||||
|
var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
var recorder = new Recorder();
|
||||||
|
|
||||||
|
walk.WalkFrame(frame.Pose.CellId, null, assembler.Landscape, ctx, recorder);
|
||||||
|
|
||||||
|
string expected = WalkTraceReplayContext.Signature(frame);
|
||||||
|
string actual = WalkTraceReplayContext.Signature(recorder.Events);
|
||||||
|
Assert.True(
|
||||||
|
expected == actual,
|
||||||
|
$"production walk diverged from the FW1 test adapter\nEXPECTED: {expected}\nACTUAL: {actual}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Doorway_still_first_frame_diff()
|
||||||
|
{
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load("posed/holtburg-doorway-still");
|
||||||
|
Assert.NotEmpty(frames);
|
||||||
|
using DatCollection dats = OpenDats();
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
WalkOracleFrame frame = frames[1];
|
||||||
|
Assert.NotNull(frame.Pose);
|
||||||
|
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
|
||||||
|
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
|
||||||
|
BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
|
||||||
|
var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
|
||||||
|
WalkCell camera = Assert.Contains(frame.Pose.CellId, cells);
|
||||||
|
// Matches WalkTraceConformanceTests.Doorway_still_first_frame_diff:
|
||||||
|
// this capture ran under cdb load with Render::deg_mul depressed to
|
||||||
|
// the portless-arm threshold. Same environment pin, same reason.
|
||||||
|
var walk = new RetailFrameWalk { DegradeMultiplier = 0f };
|
||||||
|
var recorder = new Recorder();
|
||||||
|
|
||||||
|
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
|
||||||
|
|
||||||
|
string expected = WalkTraceReplayContext.Signature(frame);
|
||||||
|
string actual = WalkTraceReplayContext.Signature(recorder.Events);
|
||||||
|
Assert.True(
|
||||||
|
expected == actual,
|
||||||
|
$"production walk diverged from the FW1 test adapter\nEXPECTED: {expected}\nACTUAL: {actual}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("posed/terrace-center")]
|
||||||
|
[InlineData("posed/terrace-edge")]
|
||||||
|
[InlineData("posed/cathedral-arrival")]
|
||||||
|
public void Still_fixture_first_frame_reproduces_exactly(string fixture)
|
||||||
|
{
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load(fixture);
|
||||||
|
Assert.NotEmpty(frames);
|
||||||
|
using DatCollection dats = OpenDats();
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
WalkOracleFrame frame = frames[1];
|
||||||
|
Assert.NotNull(frame.Pose);
|
||||||
|
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
|
||||||
|
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
|
||||||
|
BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
|
||||||
|
var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
|
||||||
|
WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
|
||||||
|
? Assert.Contains(frame.Pose.CellId, cells)
|
||||||
|
: null;
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
var recorder = new Recorder();
|
||||||
|
|
||||||
|
walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
|
||||||
|
|
||||||
|
string expected = WalkTraceReplayContext.Signature(frame);
|
||||||
|
string actual = WalkTraceReplayContext.Signature(recorder.Events);
|
||||||
|
Assert.True(
|
||||||
|
expected == actual,
|
||||||
|
$"production walk diverged from the FW1 test adapter ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Foundry_entry_reproduces_every_frame_before_the_f67_order_segment()
|
||||||
|
{
|
||||||
|
// Same F67-F79 parked segment as WalkTraceConformanceTests (the
|
||||||
|
// building-a9b40036-root-plane viewpoint question is a walk/ported-
|
||||||
|
// algorithm question, not a world-data question — out of scope for
|
||||||
|
// this class). Frames 1-66 must still reproduce exactly.
|
||||||
|
MovingFixtureReplay("posed/foundry-entry", stopBeforeFrame: 67);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("posed/holtburg-walkout")]
|
||||||
|
[InlineData("posed/holtburg-transitions")]
|
||||||
|
[InlineData("posed/holtburg-walkabout")]
|
||||||
|
public void Moving_fixture_reproduces_every_pairable_frame(string fixture)
|
||||||
|
=> MovingFixtureReplay(fixture);
|
||||||
|
|
||||||
|
private void MovingFixtureReplay(string fixture, int stopBeforeFrame = int.MaxValue)
|
||||||
|
{
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load(fixture);
|
||||||
|
Assert.True(frames.Count >= 3);
|
||||||
|
using DatCollection dats = OpenDats();
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
WalkOraclePose anchor = frames[1].Pose!;
|
||||||
|
(WalkLandscapeAssembler assembler, Dictionary<uint, WalkCell> cells,
|
||||||
|
Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> buildings) =
|
||||||
|
BuildProductionWorld(adapter, anchor.CellId, anchor.Origin);
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
|
||||||
|
for (int n = 1; n < frames.Count - 1; n++)
|
||||||
|
{
|
||||||
|
WalkOracleFrame frame = frames[n];
|
||||||
|
if (frame.Number >= stopBeforeFrame) break;
|
||||||
|
string expected = WalkTraceReplayContext.Signature(frame);
|
||||||
|
string? firstActual = null;
|
||||||
|
bool matched = false;
|
||||||
|
foreach (WalkOraclePose pose in new[] { frames[n + 1].Pose!, frame.Pose! })
|
||||||
|
{
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
assembler.SetViewer(pose.CellId, pose.Origin);
|
||||||
|
var ctx = new WalkTraceReplayContext(pose, cells) { Buildings = buildings };
|
||||||
|
WalkCell? camera = null;
|
||||||
|
if ((pose.CellId & 0xFFFFu) >= 0x100)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
cells.TryGetValue(pose.CellId, out camera),
|
||||||
|
$"frame {frame.Number}: interior camera cell {pose.CellId:x8} not loaded");
|
||||||
|
}
|
||||||
|
var recorder = new Recorder();
|
||||||
|
walk.WalkFrame(pose.CellId, camera, assembler.Landscape, ctx, recorder);
|
||||||
|
string actual = WalkTraceReplayContext.Signature(recorder.Events);
|
||||||
|
firstActual ??= actual;
|
||||||
|
if (actual == expected)
|
||||||
|
{
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.True(
|
||||||
|
matched,
|
||||||
|
$"frame {frame.Number} diverged under both adjacent poses ({fixture})\n"
|
||||||
|
+ $"EXPECTED: {expected}\nACTUAL: {firstActual}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Foundry_deep_reproduces_every_complete_frame_exactly()
|
||||||
|
{
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load("posed/foundry-deep");
|
||||||
|
Assert.NotEmpty(frames);
|
||||||
|
using DatCollection dats = OpenDats();
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
Dictionary<uint, WalkCell> cells =
|
||||||
|
WalkCellFactory.BuildInteriorCells(adapter, 0xA9B40000u, Vector3.Zero);
|
||||||
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
||||||
|
|
||||||
|
foreach (WalkOracleFrame frame in frames)
|
||||||
|
{
|
||||||
|
Assert.NotNull(frame.Pose);
|
||||||
|
WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
|
||||||
|
var ctx = new WalkTraceReplayContext(frame.Pose, cells);
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
var recorder = new Recorder();
|
||||||
|
|
||||||
|
walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
WalkTraceReplayContext.Signature(frame),
|
||||||
|
WalkTraceReplayContext.Signature(recorder.Events));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,74 @@ public sealed class LandblockBuildFactoryTests
|
||||||
Assert.Empty(result.Collisions.EnvCells);
|
Assert.Empty(result.Collisions.EnvCells);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildNear_PopulatesWalkZSlabUnconditionallyAndWalkBuildingsFromLandBlockInfo()
|
||||||
|
{
|
||||||
|
// Campaign FW3.1: EnvCellLandblockBuild.WalkMaxZ/WalkMinZ/WalkBuildings
|
||||||
|
// must be populated by the production streaming build — hermetic
|
||||||
|
// (no installed DAT needed), mirroring the RecordingDatProxy fixture
|
||||||
|
// pattern the rest of this file already uses.
|
||||||
|
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||||
|
AddNearFixture(proxy, LandblockId, environmentId: 1);
|
||||||
|
var heights = new byte[81];
|
||||||
|
Array.Fill(heights, (byte)10);
|
||||||
|
heights[0] = 200; // one outlier byte -> a distinct maxByte from the rest.
|
||||||
|
proxy.Add(LandblockId, new LandBlock { Id = LandblockId, Height = heights });
|
||||||
|
proxy.Add(
|
||||||
|
(LandblockId & 0xFFFF0000u) | 0xFFFEu,
|
||||||
|
new LandBlockInfo
|
||||||
|
{
|
||||||
|
NumCells = 1,
|
||||||
|
Buildings = new List<BuildingInfo>
|
||||||
|
{
|
||||||
|
new BuildingInfo
|
||||||
|
{
|
||||||
|
ModelId = 0x01234567u,
|
||||||
|
Frame = new Frame
|
||||||
|
{
|
||||||
|
Origin = new Vector3(12f, 12f, 0f),
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
},
|
||||||
|
Portals = new List<BuildingPortal>(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var heightTable = new float[256];
|
||||||
|
heightTable[10] = 5f;
|
||||||
|
heightTable[200] = 40f;
|
||||||
|
var factory = new LandblockBuildFactory(
|
||||||
|
dat, TestPreparedCollisionSource.Instance, new object(), heightTable);
|
||||||
|
|
||||||
|
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
|
||||||
|
result.EnvCells);
|
||||||
|
Assert.Equal(240f, envCells.WalkMaxZ); // heightTable[200] + 200
|
||||||
|
Assert.Equal(4f, envCells.WalkMinZ); // heightTable[10] - 1
|
||||||
|
AcDream.App.Rendering.Walk.WalkBuildingFactory.Entry buildingEntry =
|
||||||
|
Assert.Single(envCells.WalkBuildings);
|
||||||
|
// origin (12,12) -> cellX=cellY=0 -> low word 0*8+0+1 = 1.
|
||||||
|
Assert.Equal((LandblockId & 0xFFFF0000u) | 1u, buildingEntry.Building.PositionCellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull()
|
||||||
|
{
|
||||||
|
// Documents the known FW3.1 scope gap: far-tier (LoadFar) landblocks
|
||||||
|
// carry no EnvCellLandblockBuild transaction at all (terrain-only),
|
||||||
|
// so their z-slab/buildings never reach WalkLandscapeAssembler until
|
||||||
|
// a follow-up wires the far-tier path too.
|
||||||
|
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||||
|
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
|
||||||
|
var factory = Factory(dat, new object());
|
||||||
|
|
||||||
|
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Null(result.EnvCells);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NearPreparedFaultRejectsWholeGenerationWhileFarNeverReadsCollision()
|
public void NearPreparedFaultRejectsWholeGenerationWhileFarNeverReadsCollision()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue