acdream/src/AcDream.App/Streaming/LandblockBuildFactory.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

704 lines
37 KiB
C#

using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Streaming;
/// <summary>
/// Owns the complete CPU-side transaction for one streamed landblock. Every
/// DAT read for a build is serialized by the shared reader gate; no renderer,
/// GL, physics-world, residency, or live-origin state is reachable here.
/// The hydration and cell-registration order follows the extracted
/// WorldBuilder inventory and retail <c>CLandBlock::init_static_objs</c>
/// (0x00530A40), <c>CLandBlock::grab_visible_cells</c> (0x0052F460),
/// <c>PView::InitCell</c> (0x005A4B70), and
/// <c>CObjCell::init_objects</c> (0x0052B420).
/// See <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </summary>
public sealed class LandblockBuildFactory
{
private readonly IDatReaderWriter _dats;
private readonly IPreparedCollisionSource _preparedCollisions;
private readonly object _datLock;
private readonly float[] _heightTable;
private readonly bool _dumpSceneryZ;
public LandblockBuildFactory(
IDatReaderWriter dats,
IPreparedCollisionSource preparedCollisions,
object datLock,
float[] heightTable,
bool dumpSceneryZ = false)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_preparedCollisions = preparedCollisions ??
throw new ArgumentNullException(nameof(preparedCollisions));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
ArgumentNullException.ThrowIfNull(heightTable);
if (heightTable.Length < 256)
throw new ArgumentException(
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
_heightTable = (float[])heightTable.Clone();
_dumpSceneryZ = dumpSceneryZ;
}
/// <summary>
/// Streaming load delegate that runs on the worker thread. Reads the
/// landblock from the DATs, hydrates its entities, and returns a complete
/// CPU-side build. The shared reader gate serializes the full transaction;
/// no GL or update-thread state is accessed here.
///
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
/// LandBlockInfo + scenery + interior hydration. They return only the
/// LandBlock heightmap dat record + an empty entity list — enough for
/// terrain-mesh build on the next phase. Near-tier loads run the full
/// path. This replaces Bug A's post-load entity strip in
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
/// early-out at the source.
/// </summary>
public AcDream.App.Streaming.LandblockBuild? Build(
AcDream.App.Streaming.LandblockBuildRequest request)
{
if (!request.Origin.IsSpecified)
throw new ArgumentException(
"A landblock build requires a specified captured origin.",
nameof(request));
// DatCollection is the sole DAT reader in process, so each landblock
// must observe one serialized read transaction. Holding the shared
// gate through mesh/bounds hydration prevents another consumer from
// interleaving reader cursor/cache state with this build.
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
// contention signal — large only when the render thread is hammering the
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
// cost). Identical work in both branches; the probe branch only adds the
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
LandblockBuild? build;
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
lock (_datLock)
{
long waitedMs = sw.ElapsedMilliseconds;
sw.Restart();
build = BuildLocked(request);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"BUILD", request.LandblockId,
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
}
}
else
{
lock (_datLock)
build = BuildLocked(request);
}
if (build is null ||
request.Kind == LandblockStreamJobKind.LoadFar)
{
return build;
}
AcDream.Core.World.LandblockCollisionBuild collisions =
LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
_preparedCollisions,
build.Landblock);
AcDream.Core.World.PhysicsDatBundle publicationDats =
(build.Landblock.PhysicsDats
?? AcDream.Core.World.PhysicsDatBundle.Empty)
.WithoutCollisionGraphs();
return build with
{
Landblock = build.Landblock with
{
PhysicsDats = publicationDats,
},
Collisions = collisions,
};
}
private AcDream.App.Streaming.LandblockBuild? BuildLocked(
AcDream.App.Streaming.LandblockBuildRequest request)
{
uint landblockId = request.LandblockId;
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
// worker-thread cost per far-tier LB from ~tens of ms to a single
// dat read.
if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
{
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
if (heightmapOnly is null) return null;
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
landblockId,
heightmapOnly,
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
AcDream.Core.World.PhysicsDatBundle.Empty),
Origin: request.Origin); // far tier: no cells/buildings/entities
}
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
if (baseLoaded is null) return null;
int lbX = (int)((landblockId >> 24) & 0xFFu);
int lbY = (int)((landblockId >> 16) & 0xFFu);
var worldOffset = new System.Numerics.Vector3(
(lbX - request.Origin.CenterX) * 192f,
(lbY - request.Origin.CenterY) * 192f,
0f);
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
// expand into per-part MeshRefs via SetupMesh.Flatten.
// GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build.
IReadOnlyList<AcDream.Core.World.WorldEntity> hydrated =
LandblockPhysicsContentBuilder.HydrateStaticEntities(
_dats,
baseLoaded,
worldOffset);
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(
_dumpSceneryZ
? BuildSceneryEntitiesForStreaming(
baseLoaded,
lbX,
lbY,
request.Origin)
: LandblockPhysicsContentBuilder
.HydrateProceduralScenery(
_dats,
baseLoaded,
worldOffset,
_heightTable));
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
merged.AddRange(BuildInteriorEntitiesForStreaming(
landblockId,
lbX,
lbY,
request.Origin,
envCellBuild));
// Pre-read every DAT object publication consumes. Build this after
// `merged` so all entity GfxObj/Setup ids are known.
var physicsDats = LandblockPhysicsContentBuilder.BuildDatBundle(
_dats,
landblockId,
merged);
var completedEnvCells = envCellBuild.Build();
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
merged,
physicsDats),
completedEnvCells,
request.Origin);
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
///
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
/// LoadedLandblock instead of iterating worldView.Landblocks.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
AcDream.Core.World.LoadedLandblock lb,
int lbX,
int lbY,
AcDream.App.Streaming.LandblockBuildOrigin origin)
{
var result = new List<AcDream.Core.World.WorldEntity>();
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
if (region is null) return result;
// Build a set of terrain vertex indices that have buildings on them,
// so the scenery generator can skip those cells (ACME conformance fix 4d).
HashSet<int>? buildingCells = null;
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is not null)
{
// Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
// static scenery placeholders themselves (rocks, tree clusters) that
// retail does NOT use to suppress scenery generation. Including them
// here over-suppressed scenery in town landblocks.
buildingCells = new HashSet<int>();
foreach (var bldg in lbInfo.Buildings)
{
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8);
buildingCells.Add(cx * 9 + cy);
}
}
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable);
if (spawns.Count == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - origin.CenterX) * 192f,
(lbY - origin.CenterY) * 192f,
0f);
// Per-landblock id namespace. The top nibble identifies procedural
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
// DAT-generated landblocks from overflowing into a neighbour's range.
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
uint sceneryCounter = 0;
foreach (var spawn in spawns)
{
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
// Scale is baked into the root transform by wrapping each part's
// transform with a scale matrix.
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
}
}
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null)
{
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
// Compose: part's own transform, then the spawn's scale.
var partXf = mr.PartTransform * scaleMat;
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(partXf, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf));
}
}
}
if (meshRefs.Count == 0) continue;
// Sample terrain Z at (localX, localY) to lift scenery onto the
// ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as
// spawn.LocalPosition.Z) so meshes that specify a vertical offset
// from the ground (e.g., flowers at -0.1m, roots below terrain)
// settle properly.
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
// — it uses the same AC2D render split-direction formula the
// TerrainModernRenderer uses for the visible terrain mesh. This
// guarantees trees are placed on the SAME Z height the player
// walks on. If physics hasn't registered this landblock yet,
// fall back to the local bilinear sample.
var worldPx = localX + lbOffset.X;
var worldPy = localY + lbOffset.Y;
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
// scoped to the landblock being built. The former global
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
// build time this landblock is NOT registered in physics yet, so that query
// could only return null (→ this same own-heightmap) or a STALE neighbor's
// height — the previous location's terrain before the full old-window
// recenter retirement converges — planting scenery at the old location's
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
// case, so the global query is removed (also drops its per-spawn cost).
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
float finalZ = groundZ + spawn.LocalPosition.Z;
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
// User identifies a floating tree visually, finds the matching
// line by world coords + gfx id, the data picks the hypothesis.
if (_dumpSceneryZ)
{
// groundZ now always comes from THIS landblock's own heightmap (the
// global physics query was removed — see the trees-in-sky fix above).
string source = "heightmap";
foreach (var mr in meshRefs)
{
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (dgfx is null) continue;
float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity;
foreach (var v in dgfx.VertexArray.Vertices.Values)
{
if (v.Origin.Z < zMin) zMin = v.Origin.Z;
if (v.Origin.Z > zMax) zMax = v.Origin.Z;
}
if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; }
// Per-part transform offset inside the setup (post-spawn-scale).
// For setup spawns this is Setup.PlacementFrames[Default].Frames[i] *
// spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale.
var partT = mr.PartTransform.Translation;
bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade);
string ddInfo = string.Empty;
if (hasDD && dgfx.DIDDegrade != 0)
{
var ddi = _dats.Get<DatReaderWriter.DBObjs.GfxObjDegradeInfo>(dgfx.DIDDegrade);
if (ddi is not null && ddi.Degrades.Count > 0)
{
uint slot0Id = (uint)ddi.Degrades[0].Id;
float slot0Min = 0f;
var slot0Gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(slot0Id);
if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0)
{
slot0Min = float.PositiveInfinity;
foreach (var v in slot0Gfx.VertexArray.Vertices.Values)
if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z;
if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f;
}
ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}";
}
}
// partWorldZMin = the lowest vertex of this part in world space.
// = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex)
// If everything is right and the lowest part of the tree should
// touch the ground, we expect partWorldZMin <= groundZ for at
// least one part of a multi-part setup.
float partWorldZMin = finalZ + partT.Z + zMin;
Console.WriteLine(
$"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" +
$" source={source}" +
$" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" +
$" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" +
$" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" +
$" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" +
$" hasDIDDegrade={hasDD}{ddInfo}");
}
}
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
lbXByte,
lbYByte,
ref sceneryCounter),
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
Scale = spawn.Scale,
EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId(
lb.LandblockId,
localX,
localY),
};
if (sceneryBounds.TryGet(out var scbMin, out var scbMax))
hydrated.SetLocalBounds(scbMin, scbMax);
result.Add(hydrated);
}
return result;
}
/// <summary>
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
/// Portal cells and drawable shell placements are accumulated in the
/// transaction-local <paramref name="envCellBuild"/>. The render thread
/// cannot observe this landblock until the streaming completion carries the
/// finished transaction to <c>LandblockPresentationPipeline</c>.
///
/// Ported from pre-streaming preload lines 407-565.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
uint landblockId,
int lbX,
int lbY,
AcDream.App.Streaming.LandblockBuildOrigin origin,
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
{
var result = new List<AcDream.Core.World.WorldEntity>();
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is null || lbInfo.NumCells == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - origin.CenterX) * 192f,
(lbY - origin.CenterY) * 192f,
0f);
// 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).
//
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
// map Y-row produced the same id base, so interior statics collided across
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
// entity's batches to the other (the cache hint at bucket-draw time was the
// player's landblock, identical for both) — the session-sticky "broken stairs +
// water barrel".
//
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
// the fountain's water-spray script silently stopped firing). Widened the
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
// why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu;
uint localCounter = 0;
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
{
uint envCellId = firstCellId + offset;
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
// MUST exist in the cell dat — a null here is always a read anomaly.
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
continue;
}
// Phase 7.1: build and register room geometry for this EnvCell.
DatReaderWriter.Types.CellStruct? cellStruct = null;
if (envCell.EnvironmentId != 0)
{
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// a null Environment means this cell's WALLS are silently never
// registered while its static objects still draw — the exact
// white-walls geometry signature.
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
}
if (environment is not null
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
// drawable-geometry predicate; the actual shell placement is now owned
// by this streaming job's EnvCellLandblockBuild transaction.
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer receives only the completed shell transaction.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
// shared with every draw-space consumer of portal polygons (OutsideView
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
// to the cells beyond — otherwise the flood lookup-misses the unregistered
// neighbour and the grey clear shows through the opening (#133: ramp
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; the cell-build transaction reads portals, NOT
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
// is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
envCellBuild.AddCell(
envCellId,
envCell,
cellStruct,
physicsCellOrigin,
physicsCellTransform,
cellOrigin,
cellTransform,
hasDrawableGeometry: cellSubMeshes.Count > 0);
}
}
// Phase 2d: static objects inside the EnvCell.
foreach (var stab in envCell.StaticObjects)
{
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
// targeted stabs. This is the MOMENT MeshRefs are constructed —
// a degraded dat read here (setup null / placement frames short /
// part GfxObj null) permanently corrupts the entity (H-A), and
// nothing downstream ever rebuilds it. Inert when the set is empty.
bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics
.DumpEntitySourceIds.Contains(stab.Id);
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
// WorldBuilder editor shows it at the origin. acdream's render path came from
// WB (no distance LOD), so without this skip it draws the marker forever (the
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
if ((stab.Id & 0xFF000000u) == 0x01000000u
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
continue;
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
// the entity that otherwise carries those lights to the static
// presentation publisher.
int stabLightCount = 0;
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
}
else if (dumpStab)
{
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped");
}
}
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
if (setup is not null)
{
stabLightCount = setup.Lights.Count;
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
if (dumpStab)
{
dumpSetupParts = setup.Parts.Count;
dumpPlacementFrames = setup.PlacementFrames.Count;
dumpFlattened = flat.Count;
}
foreach (var mr in flat)
{
// #136: skip an editor-only marker PART (retail hides it at runtime
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
// part GfxObj 0x010028CA is such a marker — skipping it empties
// meshRefs and the whole stab drops below.
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null)
{
dumpDropped++;
if (dumpStab)
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
continue;
}
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr);
}
}
else if (dumpStab)
{
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped");
}
}
if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount))
{
if (dumpStab)
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped");
continue;
}
if (meshRefs.Count == 0 && dumpStab)
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier");
// Stabs inside EnvCells are already in landblock-local coordinates
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
// be wrong — see Phase 2d comment in the pre-streaming preload.
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
interiorLbX,
interiorLbY,
ref localCounter),
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
MeshRefs = meshRefs,
ParentCellId = envCellId,
};
if (interiorBounds.TryGet(out var ibMin, out var ibMax))
hydrated.SetLocalBounds(ibMin, ibMax);
if (dumpStab)
{
Console.WriteLine(
$"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " +
$"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " +
$"built={meshRefs.Count} dropped={dumpDropped} " +
$"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})");
for (int i = 0; i < meshRefs.Count; i++)
{
var t = meshRefs[i].PartTransform.Translation;
Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})");
}
}
result.Add(hydrated);
}
}
return result;
}
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
{
uint landblockX = (block.Id >> 24) & 0xFFu;
uint landblockY = (block.Id >> 16) & 0xFFu;
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
block.Height, heightTable, landblockX, landblockY, localX, localY);
}
}