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:
Erik 2026-08-30 13:19:22 +02:00
parent b95850defe
commit b10ad662b0
14 changed files with 1711 additions and 2 deletions

View file

@ -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;

View file

@ -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++;
}