using DatReaderWriter; using AcDream.Content; namespace AcDream.App.Streaming; /// /// 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 CLandBlock::init_static_objs /// (0x00530A40), CLandBlock::grab_visible_cells (0x0052F460), /// PView::InitCell (0x005A4B70), and /// CObjCell::init_objects (0x0052B420). /// See docs/architecture/worldbuilder-inventory.md. /// 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; } /// /// 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 (kind == LoadFar) 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 /// with an /// early-out at the source. /// 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(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.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 hydrated = LandblockPhysicsContentBuilder.HydrateStaticEntities( _dats, baseLoaded, worldOffset); // Task 8: merge stabs + scenery + interior into one entity list. var merged = new List(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); } /// /// 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. /// private List BuildSceneryEntitiesForStreaming( AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY, AcDream.App.Streaming.LandblockBuildOrigin origin) { var result = new List(); var region = _dats.Get(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? buildingCells = null; var lbInfo = _dats.Get( (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(); 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(); var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(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(spawn.ObjectId); if (setup is not null) { var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(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(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(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(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; } /// /// 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 . The render thread /// cannot observe this landblock until the streaming completion carries the /// finished transaction to LandblockPresentationPipeline. /// /// Ported from pre-streaming preload lines 407-565. /// private List BuildInteriorEntitiesForStreaming( uint landblockId, int lbX, int lbY, AcDream.App.Streaming.LandblockBuildOrigin origin, AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild) { var result = new List(); var lbInfo = _dats.Get((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(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(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(); 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(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(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(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); } }