acdream/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
Erik 05240d2cab feat(render) Campaign FW3.3: ShellDrawLiftZ is RETIRED - cells draw at the dat origin
Retail draws cell geometry at the dat EnvCell origin verbatim; the
0.02 m lift was our z-fight stand-in (register row AP-32, deleted in
this commit). With the walk owning retail draw ORDER under
WorldDepthContract Less (first-drawn-wins: DrawBlock terrain-then-
objects per cell, DrawCells shells-then-contents), the coplanar
tie-breaks the lift compensated for are now resolved the way retail
resolves them.

Deleted at every site: the PortalVisibilityBuilder const + the
drawLiftZ Build parameter and its lifted exit-portal projection branch
(gate and drawn geometry now share ONE space); the seal/punch fan
lifts (DrawPortalDepthWrite + the walk's DrawWalkPunchFan); the
LandblockBuildFactory drawn-cell-transform lift (render and physics
share the one verbatim transform).

The #130 proof flipped exactly as its own doc predicted:
UnliftedGate_LeavesTheStripAtTheDrawnTopEdge is deleted (its premise -
gate space != drawn space - no longer exists), and the renamed
ExitDoorTopEdge_GateCoversTheDrawnApertureWithinPixelTolerance sweep
(147 eye/gaze combos at the Holtburg corner door) passes with both in
the same unlifted space (worst plane gap <= 1.2 px, scissor <= 0.15 px
- unchanged tolerances). Ten more replay-test call sites swept to the
new Build signature.

Suites: full Release build 0 warnings; hermetic 6,750/0; the 21
affected InstalledDat replay tests green; Walk conformance 40/1
untouched.

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

363 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Rendering.Wb;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// One drawable EnvCell shell prepared by the streaming worker. This is CPU-only
/// placement data; the render thread schedules mesh preparation when it commits
/// the containing <see cref="EnvCellLandblockBuild"/>.
/// </summary>
public sealed record EnvCellShellPlacement(
uint CellId,
ulong GeometryId,
uint EnvironmentId,
ushort CellStructure,
ImmutableArray<ushort> Surfaces,
Vector3 WorldPosition,
Quaternion Rotation,
Matrix4x4 Transform,
WbBoundingBox LocalBounds,
WbBoundingBox WorldBounds);
/// <summary>
/// Complete, immutable worker output for one landblock's indoor cells. It owns
/// both portal-visibility cells and drawable shell placements so neither can be
/// drained by, or mixed with, another streaming completion.
/// </summary>
public sealed class EnvCellLandblockBuild
{
public EnvCellLandblockBuild(
uint landblockId,
IEnumerable<LoadedCell> visibilityCells,
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>
/// Transaction-local builder used only by one streaming job. Unlike the former
/// global pending bags, instances of this class are never shared between jobs or
/// observed by the render thread before <see cref="Build"/> returns.
/// </summary>
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)
{
_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,
CellStruct cellStruct,
Vector3 physicsCellOrigin,
Matrix4x4 physicsCellTransform,
Vector3 shellWorldPosition,
Matrix4x4 shellTransform,
bool hasDrawableGeometry)
{
if (_built)
throw new InvalidOperationException("This landblock cell build is already complete.");
var localBounds = ComputeLocalBounds(cellStruct);
_visibilityCells.Add(BuildVisibilityCell(
envCellId,
envCell,
cellStruct,
physicsCellOrigin,
physicsCellTransform,
localBounds));
if (!hasDrawableGeometry)
return;
ulong geometryId = ComputeGeometryId(
envCell.EnvironmentId,
envCell.CellStructure,
envCell.Surfaces);
_shells.Add(new EnvCellShellPlacement(
envCellId,
geometryId,
envCell.EnvironmentId,
envCell.CellStructure,
envCell.Surfaces.ToImmutableArray(),
shellWorldPosition,
envCell.Position.Orientation,
shellTransform,
localBounds,
TransformBoundingBox(localBounds, shellTransform)));
}
public EnvCellLandblockBuild Build()
{
if (_built)
throw new InvalidOperationException("This landblock cell build is already complete.");
_built = true;
return new EnvCellLandblockBuild(
_landblockId, _visibilityCells, _shells, _walkBuildings, _walkMaxZ, _walkMinZ);
}
/// <summary>
/// Collision-resistant successor to WB EnvCellRenderManager.cs:94-103.
/// The legacy polynomial and its installed-DAT collision remain covered by
/// Core conformance tests.
/// </summary>
public static ulong ComputeGeometryId(
uint environmentId,
ushort cellStructure,
IReadOnlyList<ushort> surfaces)
=> EnvCellGeometryIdentity.Compute(environmentId, cellStructure, surfaces);
private static WbBoundingBox ComputeLocalBounds(CellStruct cellStruct)
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var vertex in cellStruct.VertexArray.Vertices.Values)
{
var position = new Vector3(vertex.Origin.X, vertex.Origin.Y, vertex.Origin.Z);
min = Vector3.Min(min, position);
max = Vector3.Max(max, position);
}
return min.X == float.MaxValue
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
: new WbBoundingBox(min, max);
}
private static WbBoundingBox TransformBoundingBox(WbBoundingBox box, Matrix4x4 transform)
{
Span<Vector3> corners = stackalloc Vector3[8]
{
new(box.Min.X, box.Min.Y, box.Min.Z),
new(box.Max.X, box.Min.Y, box.Min.Z),
new(box.Min.X, box.Max.Y, box.Min.Z),
new(box.Max.X, box.Max.Y, box.Min.Z),
new(box.Min.X, box.Min.Y, box.Max.Z),
new(box.Max.X, box.Min.Y, box.Max.Z),
new(box.Min.X, box.Max.Y, box.Max.Z),
new(box.Max.X, box.Max.Y, box.Max.Z),
};
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var corner in corners)
{
var transformed = Vector3.Transform(corner, transform);
min = Vector3.Min(min, transformed);
max = Vector3.Max(max, transformed);
}
return new WbBoundingBox(min, max);
}
/// <summary>
/// Mechanical extraction of the former GameWindow.BuildLoadedCell. Portal-side behavior
/// is unchanged: retail PView::InitCell (0x005a4b70) uses the dat PortalSide
/// bit, and the reciprocal portal index follows the named retail path at
/// pseudo-C line 433557.
/// </summary>
private static LoadedCell BuildVisibilityCell(
uint envCellId,
EnvCell envCell,
CellStruct cellStruct,
Vector3 cellOrigin,
Matrix4x4 cellTransform,
WbBoundingBox localBounds)
{
Matrix4x4.Invert(cellTransform, out var inverse);
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<Vector3[]>();
foreach (var portal in envCell.CellPortals)
{
portals.Add(new CellPortalInfo(
portal.OtherCellId,
portal.PolygonId,
(ushort)portal.Flags,
portal.OtherPortalId));
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
&& poly.VertexIds.Count >= 3)
{
Vector3 p0 = Vector3.Zero, p1 = Vector3.Zero, p2 = Vector3.Zero;
bool found = true;
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
else
found = false;
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
else
found = false;
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
else
found = false;
if (found)
{
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0;
clipPlanes.Add(new PortalClipPlane
{
Normal = normal,
D = d,
InsideSide = insideSide,
});
}
else
{
clipPlanes.Add(default);
}
}
else
{
clipPlanes.Add(default);
}
Vector3[] polygonVertices = Array.Empty<Vector3>();
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPolygon)
&& portalPolygon.VertexIds.Count >= 3)
{
polygonVertices = new Vector3[portalPolygon.VertexIds.Count];
bool allResolved = true;
for (int vertexIndex = 0; vertexIndex < portalPolygon.VertexIds.Count; vertexIndex++)
{
if (cellStruct.VertexArray.Vertices.TryGetValue(
(ushort)portalPolygon.VertexIds[vertexIndex], out var vertex))
{
polygonVertices[vertexIndex] = new Vector3(
vertex.Origin.X,
vertex.Origin.Y,
vertex.Origin.Z);
}
else
{
allResolved = false;
break;
}
}
if (!allResolved)
polygonVertices = Array.Empty<Vector3>();
}
portalPolygons.Add(polygonVertices);
}
uint landblockPrefix = envCellId & 0xFFFF0000u;
var visibleCells = new List<uint>();
if (envCell.VisibleCells is not null)
{
foreach (var lowId in envCell.VisibleCells)
visibleCells.Add(landblockPrefix | lowId);
}
return new LoadedCell
{
CellId = envCellId,
WorldPosition = cellOrigin,
WorldTransform = cellTransform,
InverseWorldTransform = inverse,
LocalBoundsMin = localBounds.Min,
LocalBoundsMax = localBounds.Max,
Portals = portals,
ClipPlanes = clipPlanes,
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 are the dat EnvCell origin verbatim —
// since FW3.3 retired ShellDrawLiftZ, render and physics share
// this one transform (retail draws at the origin; the walk's
// retail draw order owns the coplanar tie-breaks).
Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse),
};
}
}