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.Numerics;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
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>
|
||||
|
|
|
|||
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.Numerics;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.Core.Rendering.Wb;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
|
|
@ -34,22 +35,48 @@ public sealed class EnvCellLandblockBuild
|
|||
public EnvCellLandblockBuild(
|
||||
uint landblockId,
|
||||
IEnumerable<LoadedCell> visibilityCells,
|
||||
IEnumerable<EnvCellShellPlacement> shells)
|
||||
IEnumerable<EnvCellShellPlacement> shells,
|
||||
IEnumerable<WalkBuildingFactory.Entry>? walkBuildings = null,
|
||||
float walkMaxZ = 0f,
|
||||
float walkMinZ = 0f)
|
||||
{
|
||||
LandblockId = landblockId;
|
||||
VisibilityCells = visibilityCells.ToImmutableArray();
|
||||
Shells = shells.ToImmutableArray();
|
||||
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
|
||||
WalkMaxZ = walkMaxZ;
|
||||
WalkMinZ = walkMinZ;
|
||||
|
||||
uint expectedPrefix = landblockId & 0xFFFF0000u;
|
||||
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
|
||||
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||||
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 ImmutableArray<LoadedCell> VisibilityCells { 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>
|
||||
|
|
@ -62,6 +89,9 @@ public sealed class EnvCellLandblockBuildBuilder
|
|||
private readonly uint _landblockId;
|
||||
private readonly List<LoadedCell> _visibilityCells = new();
|
||||
private readonly List<EnvCellShellPlacement> _shells = new();
|
||||
private readonly List<WalkBuildingFactory.Entry> _walkBuildings = new();
|
||||
private float _walkMaxZ;
|
||||
private float _walkMinZ;
|
||||
private bool _built;
|
||||
|
||||
public EnvCellLandblockBuildBuilder(uint landblockId)
|
||||
|
|
@ -69,6 +99,31 @@ public sealed class EnvCellLandblockBuildBuilder
|
|||
_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(
|
||||
uint envCellId,
|
||||
EnvCell envCell,
|
||||
|
|
@ -116,7 +171,8 @@ public sealed class EnvCellLandblockBuildBuilder
|
|||
if (_built)
|
||||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||||
_built = true;
|
||||
return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells);
|
||||
return new EnvCellLandblockBuild(
|
||||
_landblockId, _visibilityCells, _shells, _walkBuildings, _walkMaxZ, _walkMinZ);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -290,6 +346,20 @@ public sealed class EnvCellLandblockBuildBuilder
|
|||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
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,
|
||||
_heightTable));
|
||||
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(
|
||||
landblockId,
|
||||
lbX,
|
||||
|
|
@ -448,6 +457,17 @@ public sealed class LandblockBuildFactory
|
|||
(lbY - origin.CenterY) * 192f,
|
||||
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
|
||||
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
||||
// 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)
|
||||
{
|
||||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Diagnostics;
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.Core.Terrain;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -102,6 +103,17 @@ public sealed class LandblockRenderPublisher
|
|||
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
|
||||
private readonly Action<uint>? _removeEnvCells;
|
||||
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 _completeCount;
|
||||
|
|
@ -148,6 +160,15 @@ public sealed class LandblockRenderPublisher
|
|||
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
|
||||
_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(
|
||||
_beginCount,
|
||||
_completeCount,
|
||||
|
|
@ -339,6 +360,21 @@ public sealed class LandblockRenderPublisher
|
|||
_buildingRegistries[registryKey] =
|
||||
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;
|
||||
}
|
||||
else if (publication.EnvCellPublication is { } envCellPublication
|
||||
|
|
@ -397,6 +433,14 @@ public sealed class LandblockRenderPublisher
|
|||
public void RemoveBuildingRegistry(uint landblockId)
|
||||
{
|
||||
_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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue