From 5af101b2f1df6fb5ea3f9ccdf8aa38c86b65f73a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:30:32 +0200 Subject: [PATCH] refactor(streaming): extract landblock build factory --- src/AcDream.App/Rendering/GameWindow.cs | 800 +---------------- .../Streaming/LandblockBuildFactory.cs | 808 ++++++++++++++++++ .../Streaming/LandblockBuildFactoryTests.cs | 509 +++++++++++ .../Streaming/LandblockBuildOriginTests.cs | 38 +- 4 files changed, 1361 insertions(+), 794 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockBuildFactory.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ac7aca12..21b41a7a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -205,26 +205,23 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe. // DatReaderWriter's DatBinReader uses a shared buffer position internally — // concurrent _dats.Get calls from the streaming worker thread (T11+) and - // the render thread (BuildLandblockForStreaming on the worker; - // ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render + // the render thread (LandblockBuildFactory on the worker; live-spawn + // handlers + animation ticks on the render // thread) corrupt that state and produce half-populated LandBlock.Height[] // arrays, rendering as "a giant ball with spikes". All _dats.Get call // sites that can race with the streaming worker MUST hold this lock. // - // Worker-thread dat reads enter via the factory closures passed to - // LandblockStreamer at construction (loadLandblock + buildMeshOrNull). - // Those closures already acquire _datLock, so no additional wrapping is - // needed for reads inside BuildLandblockForStreamingLocked / - // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. - // Render-thread paths (ApplyLoadedTerrain and live hydration) already - // hold this lock via their outer wrappers; all remaining render-thread + // Worker-thread DAT reads enter through LandblockBuildFactory.Build, which + // holds _datLock for the complete CPU transaction. The terrain-mesh closure + // consumes that completed heightmap and performs no DAT read. Render-thread + // live hydration holds this lock via its outer wrapper; all remaining + // render-thread // _dats.Get calls run only when no worker dat read can be in flight (during // initialization or within the same lock scope). private readonly object _datLock = new(); - // Terrain build context shared across all streamed landblocks. Stored as - // fields so ApplyLoadedTerrain (render-thread callback) can call - // LandblockMesh.Build without re-deriving these each time. + // Terrain build context shared by the off-thread mesh-build closure across + // all streamed landblocks. private float[]? _heightTable; private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx; // Was: Dictionary. ConcurrentDictionary so the off-thread @@ -2491,8 +2488,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so // it can call LandblockMesh.Build without a dat read — _heightTable and // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). + var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory( + _dats!, + _datLock, + _heightTable!, + _physicsDataCache, + _options.DumpSceneryZ); _streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests( - loadLandblock: BuildLandblockForStreaming, + loadLandblock: landblockBuildFactory.Build, buildMeshOrNull: (id, lb) => { if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) @@ -3078,15 +3081,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - - 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); - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -3780,774 +3774,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - - /// - /// Phase A.1: streaming load delegate, runs on the worker thread. - /// Reads the landblock from the dats, hydrates its stab entities (same - /// path as the old preload), and returns a fully-populated LoadedLandblock. - /// Thread-safe: uses only DatCollection reads (documented thread-safe by - /// DatReaderWriter) and pure CPU work. No GL calls here. - /// - /// MVP scope: stabs only. Scenery + interior added in Task 8. - /// - /// 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. - /// - private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming( - AcDream.App.Streaming.LandblockBuildRequest request) - { - if (_dats is null) return null; - - // Phase A.1 hotfix: hold the dat lock for the entire load. The worker - // thread mustn't read dats concurrently with the render thread's - // ApplyLoadedTerrain / live-spawn handlers. Hold time is bounded by - // the size of a single landblock's CPU-side build (tens of ms worst - // case), which blocks the render thread for at most that duration. - // This is the minimum correct behavior; a future pass can reduce - // contention by pre-building render-thread work on the worker. - // 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. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - lock (_datLock) - { - long waitedMs = sw.ElapsedMilliseconds; - sw.Restart(); - var built = BuildLandblockForStreamingLocked(request); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "BUILD", request.LandblockId, - $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); - return built; - } - } - lock (_datLock) - { - return BuildLandblockForStreamingLocked(request); - } - } - - private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked( - AcDream.App.Streaming.LandblockBuildRequest request) - { - if (_dats is null) return null; - 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 / GfxObjMesh.Build. - // GPU upload (EnsureUploaded) happens on the render thread in - // ApplyLoadedTerrain — NOT here. - var hydrated = new List(baseLoaded.Entities.Count); - foreach (var e in baseLoaded.Entities) - { - var meshRefs = new List(); - var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - - if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) - { - // Single GfxObj stab — identity part transform. - var gfx = _dats.Get(e.SourceGfxObjOrSetupId); - if (gfx is not null) - { - _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); - meshRefs.Add(new AcDream.Core.World.MeshRef( - e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); - } - } - else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - // Multi-part Setup — flatten to per-part GfxObj refs. - var setup = _dats.Get(e.SourceGfxObjOrSetupId); - if (setup is not null) - { - _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); - meshRefs.Add(mr); - } - } - } - - if (meshRefs.Count == 0) continue; - - var entity = new AcDream.Core.World.WorldEntity - { - Id = e.Id, - SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, - Position = e.Position + worldOffset, - Rotation = e.Rotation, - MeshRefs = meshRefs, - EffectCellId = e.EffectCellId, - IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag - BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, - }; - if (stabBounds.TryGet(out var sbMin, out var sbMax)) - entity.SetLocalBounds(sbMin, sbMax); - hydrated.Add(entity); - } - - // Task 8: merge stabs + scenery + interior into one entity list. - var merged = new List(hydrated); - merged.AddRange(BuildSceneryEntitiesForStreaming( - baseLoaded, - lbX, - lbY, - request.Origin)); - var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId); - merged.AddRange(BuildInteriorEntitiesForStreaming( - landblockId, - lbX, - lbY, - request.Origin, - envCellBuild)); - - // datLock fix: pre-read (under the worker's _datLock) every dat the apply - // consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER - // `merged` so entity GfxObj/Setup ids are known. - var physicsDats = BuildPhysicsDatBundle(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); - } - private void EnsureEnvCellMeshesAfterPin( AcDream.App.Rendering.Wb.EnvCellLandblockBuild build) { if (_wbMeshAdapter?.MeshManager is { } meshManager) AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager); } - - /// - /// Pre-reads (under the worker's _datLock) every dat object - /// consumes, so the apply makes zero - /// DatCollection calls and the update thread never blocks on the - /// worker's lock. MIRRORS the apply's six read sites — gather and apply must - /// enumerate the same ids. Reads only; no build / registration. - /// - private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( - uint landblockId, - System.Collections.Generic.IReadOnlyList entities) - { - var envCells = new System.Collections.Generic.Dictionary(); - var environments = new System.Collections.Generic.Dictionary(); - var setups = new System.Collections.Generic.Dictionary(); - var gfxObjs = new System.Collections.Generic.Dictionary(); - - // (1) LandBlockInfo - var lbInfo = _dats!.Get( - (landblockId & 0xFFFF0000u) | 0xFFFEu); - - if (lbInfo is not null) - { - // (2)+(3) EnvCell + Environment, per cell - if (lbInfo.NumCells > 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) continue; - envCells[envCellId] = envCell; - if (envCell.EnvironmentId == 0) continue; - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (!environments.ContainsKey(envId)) - { - var environment = _dats.Get(envId); - if (environment is not null) environments[envId] = environment; - } - } - } - - // (4) building shell Setup, per building - foreach (var building in lbInfo.Buildings) - { - uint modelId = building.ModelId; - if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) - { - var bldSetup = _dats.Get(modelId); - if (bldSetup is not null) setups[modelId] = bldSetup; - } - } - } - - // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights) - foreach (var entity in entities) - { - uint src = entity.SourceGfxObjOrSetupId; - if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) - { - var s = _dats.Get(src); - if (s is not null) setups[src] = s; - } - foreach (var meshRef in entity.MeshRefs) - { - uint gid = meshRef.GfxObjId; - if ((gid & 0xFF000000u) != 0x01000000u) continue; - if (gfxObjs.ContainsKey(gid)) continue; - var gfx = _dats.Get(gid); - if (gfx is not null) gfxObjs[gid] = gfx; - } - } - - return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); - } - - /// - /// 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(); - if (_dats is null || _heightTable is null) return result; - - 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) - { - _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); - // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - 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) - { - _physicsDataCache.CacheSetup(spawn.ObjectId, setup); - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - // 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, still registered after a teleport - // recenter (which drops only the single stale CENTER landblock, GameWindow - // :5444) until streaming unloads it — 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 (_options.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 ApplyLoadedTerrain. - /// - /// 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(); - if (_dats is null) return result; - - 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 would otherwise carry those lights to the registration - // pass (GameWindow.cs ~7900). - int stabLightCount = 0; - if ((stab.Id & 0xFF000000u) == 0x01000000u) - { - var gfx = _dats.Get(stab.Id); - if (gfx is not null) - { - _physicsDataCache.CacheGfxObj(stab.Id, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - 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) - { - _physicsDataCache.CacheSetup(stab.Id, setup); - 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; - } - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - 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 void AdvanceLandblockPresentationRetirement( AcDream.App.Streaming.LandblockRetirementTicket ticket) { diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs new file mode 100644 index 00000000..1fc1903a --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -0,0 +1,808 @@ +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 object _datLock; + private readonly float[] _heightTable; + private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache; + private readonly bool _dumpSceneryZ; + + public LandblockBuildFactory( + IDatReaderWriter dats, + object datLock, + float[] heightTable, + AcDream.Core.Physics.PhysicsDataCache physicsDataCache, + bool dumpSceneryZ = false) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _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(); + _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); + _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. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + lock (_datLock) + { + long waitedMs = sw.ElapsedMilliseconds; + sw.Restart(); + var built = BuildLocked(request); + AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( + "BUILD", request.LandblockId, + $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); + return built; + } + } + lock (_datLock) + { + return BuildLocked(request); + } + } + + 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 / GfxObjMesh.Build. + // GPU upload (EnsureUploaded) happens on the render thread in + // ApplyLoadedTerrain — NOT here. + var hydrated = new List(baseLoaded.Entities.Count); + foreach (var e in baseLoaded.Entities) + { + var meshRefs = new List(); + var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + // Single GfxObj stab — identity part transform. + var gfx = _dats.Get(e.SourceGfxObjOrSetupId); + if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef( + e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); + } + } + else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + // Multi-part Setup — flatten to per-part GfxObj refs. + var setup = _dats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count == 0) continue; + + var entity = new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position + worldOffset, + Rotation = e.Rotation, + MeshRefs = meshRefs, + EffectCellId = e.EffectCellId, + IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag + BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, + }; + if (stabBounds.TryGet(out var sbMin, out var sbMax)) + entity.SetLocalBounds(sbMin, sbMax); + hydrated.Add(entity); + } + + // Task 8: merge stabs + scenery + interior into one entity list. + var merged = new List(hydrated); + merged.AddRange(BuildSceneryEntitiesForStreaming( + baseLoaded, + lbX, + lbY, + request.Origin)); + 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 = BuildPhysicsDatBundle(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); + } + + + + /// + /// Pre-reads every DAT object that landblock publication consumes, so the + /// update thread performs no DAT reads. Gather and publication must + /// enumerate the same identifiers. Reads only; no runtime registration. + /// + private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( + uint landblockId, + System.Collections.Generic.IReadOnlyList entities) + { + var envCells = new System.Collections.Generic.Dictionary(); + var environments = new System.Collections.Generic.Dictionary(); + var setups = new System.Collections.Generic.Dictionary(); + var gfxObjs = new System.Collections.Generic.Dictionary(); + + // (1) LandBlockInfo + var lbInfo = _dats!.Get( + (landblockId & 0xFFFF0000u) | 0xFFFEu); + + if (lbInfo is not null) + { + // (2)+(3) EnvCell + Environment, per cell + if (lbInfo.NumCells > 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) continue; + envCells[envCellId] = envCell; + if (envCell.EnvironmentId == 0) continue; + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (!environments.ContainsKey(envId)) + { + var environment = _dats.Get(envId); + if (environment is not null) environments[envId] = environment; + } + } + } + + // (4) building shell Setup, per building + foreach (var building in lbInfo.Buildings) + { + uint modelId = building.ModelId; + if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) + { + var bldSetup = _dats.Get(modelId); + if (bldSetup is not null) setups[modelId] = bldSetup; + } + } + } + + // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights) + foreach (var entity in entities) + { + uint src = entity.SourceGfxObjOrSetupId; + if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) + { + var s = _dats.Get(src); + if (s is not null) setups[src] = s; + } + foreach (var meshRef in entity.MeshRefs) + { + uint gid = meshRef.GfxObjId; + if ((gid & 0xFF000000u) != 0x01000000u) continue; + if (gfxObjs.ContainsKey(gid)) continue; + var gfx = _dats.Get(gid); + if (gfx is not null) gfxObjs[gid] = gfx; + } + } + + return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); + } + + /// + /// 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) + { + _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); + // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + 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) + { + _physicsDataCache.CacheSetup(spawn.ObjectId, setup); + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + // 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, still registered after a teleport + // recenter (which drops only the single stale CENTER landblock, GameWindow + // :5444) until streaming unloads it — 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 ApplyLoadedTerrain. + /// + /// 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 would otherwise carry those lights to the registration + // pass (GameWindow.cs ~7900). + int stabLightCount = 0; + if ((stab.Id & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(stab.Id); + if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(stab.Id, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + 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) + { + _physicsDataCache.CacheSetup(stab.Id, setup); + 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; + } + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + 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); + } + +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs new file mode 100644 index 00000000..62108bd3 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs @@ -0,0 +1,509 @@ +using System.Reflection; +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit.Sdk; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockBuildFactoryTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private const uint AdjacentLandblockId = 0xA9B5FFFFu; + private static readonly LandblockBuildOrigin HoltburgOrigin = new(0xA9, 0xB4); + + [Fact] + public void BuildFar_ReturnsOnlyHeightmapAndCapturedOrigin() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var heightmap = new LandBlock { Id = LandblockId }; + proxy.Add(LandblockId, heightmap); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar)); + + Assert.NotNull(result); + Assert.Same(heightmap, result.Landblock.Heightmap); + Assert.Empty(result.Landblock.Entities); + Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats); + Assert.Null(result.EnvCells); + Assert.Equal(HoltburgOrigin, result.Origin); + Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); + } + + [Fact] + public void Build_RejectsUnspecifiedOriginBeforeReadingDat() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var factory = Factory(dat, new object()); + + Assert.Throws(() => factory.Build( + new LandblockBuildRequest( + LandblockId, + LandblockStreamJobKind.LoadFar, + Generation: 1, + Origin: default))); + Assert.Empty(proxy.Reads); + } + + [Fact] + public void Build_MissingHeightmapReturnsNullWithoutPartialPayload() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar)); + + Assert.Null(result); + Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); + } + + [Fact] + public void BuildNear_MissingLandblockInfoReturnsCompleteEmptyNearPayload() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear)); + + Assert.NotNull(result); + Assert.Empty(result.Landblock.Entities); + var envCells = Assert.IsType( + result.EnvCells); + Assert.Empty(envCells.VisibilityCells); + Assert.Empty(envCells.Shells); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Null(physics.Info); + Assert.Empty(physics.EnvCells); + } + + [Fact] + public void BuildNear_MissingEnvCellSkipsOnlyThatCellWithoutPartialVisibilityEntry() + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2); + uint missingCell = (LandblockId & 0xFFFF0000u) | 0x0101u; + proxy.Remove(missingCell); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear)); + + Assert.NotNull(result); + var envCells = Assert.IsType( + result.EnvCells); + Assert.Equal( + [(LandblockId & 0xFFFF0000u) | 0x0100u], + envCells.VisibilityCells.Select(cell => cell.CellId)); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Single(physics.EnvCells); + Assert.DoesNotContain(missingCell, physics.EnvCells.Keys); + } + + [Fact] + public async Task Build_SerializesCompleteNearTransactionsOnSharedGate() + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1); + AddNearFixture(proxy, AdjacentLandblockId, environmentId: 2); + object gate = new(); + var factory = Factory(dat, gate); + + Assert.NotNull(factory.Build(Request( + LandblockId, + LandblockStreamJobKind.LoadNear, + generation: 1, + HoltburgOrigin))); + (Type Type, uint Id)[] firstSequence = proxy.Reads.ToArray(); + proxy.ClearReads(); + Assert.NotNull(factory.Build(Request( + AdjacentLandblockId, + LandblockStreamJobKind.LoadNear, + generation: 1, + new LandblockBuildOrigin(0xA9, 0xB5)))); + (Type Type, uint Id)[] secondSequence = proxy.Reads.ToArray(); + proxy.ClearReads(); + proxy.ReadDelay = TimeSpan.FromMilliseconds(5); + + Task first = Task.Run(() => + factory.Build(Request( + LandblockId, + LandblockStreamJobKind.LoadNear, + generation: 2, + HoltburgOrigin))); + Task second = Task.Run(() => + factory.Build(Request( + AdjacentLandblockId, + LandblockStreamJobKind.LoadNear, + generation: 3, + new LandblockBuildOrigin(0xA9, 0xB5)))); + LandblockBuild?[] results = await Task.WhenAll(first, second); + + Assert.All(results, Assert.NotNull); + Assert.Equal(1, proxy.MaxConcurrentReads); + (Type Type, uint Id)[] combined = proxy.Reads.ToArray(); + Assert.True( + combined.SequenceEqual(firstSequence.Concat(secondSequence)) + || combined.SequenceEqual(secondSequence.Concat(firstSequence)), + "Near-build DAT reads interleaved instead of remaining one serialized transaction."); + } + + [Fact] + public async Task Build_UsesTheSuppliedSharedReaderGate() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + object gate = new(); + var factory = Factory(dat, gate); + using var started = new ManualResetEventSlim(); + + Monitor.Enter(gate); + Task build; + try + { + build = Task.Run(() => + { + started.Set(); + return factory.Build(Request(LandblockStreamJobKind.LoadFar)); + }); + Assert.True(started.Wait(TimeSpan.FromSeconds(2))); + Assert.False(proxy.ReadObserved.Wait(TimeSpan.FromMilliseconds(100))); + } + finally + { + Monitor.Exit(gate); + } + + Assert.NotNull(await build); + Assert.True(proxy.ReadObserved.IsSet); + } + + [Theory] + [InlineData(LandblockStreamJobKind.LoadNear)] + [InlineData(LandblockStreamJobKind.PromoteToNear)] + public void Build_NearAndPromoteIncludeEveryValidZeroSubmeshVisibilityCell( + LandblockStreamJobKind kind) + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(kind)); + + Assert.NotNull(result); + var envCells = Assert.IsType( + result.EnvCells); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Equal( + [(LandblockId & 0xFFFF0000u) | 0x0100u, (LandblockId & 0xFFFF0000u) | 0x0101u], + envCells.VisibilityCells.Select(cell => cell.CellId)); + Assert.Empty(envCells.Shells); + Assert.NotNull(physics.Info); + Assert.Equal(2, physics.EnvCells.Count); + Assert.Single(physics.Environments); + } + + [Fact] + public void ConstructorSnapshotsTheRetailHeightTable() + { + var dat = CreateDat(out _); + var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray(); + var factory = new LandblockBuildFactory( + dat, + new object(), + heights, + new PhysicsDataCache()); + heights[42] = -1f; + + var snapshot = Assert.IsType(typeof(LandblockBuildFactory) + .GetField("_heightTable", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(factory)); + Assert.NotSame(heights, snapshot); + Assert.Equal(42f, snapshot[42]); + } + + [Fact] + public void ConstructorRejectsIncompleteRetailHeightTable() + { + var dat = CreateDat(out _); + + Assert.Throws(() => new LandblockBuildFactory( + dat, + new object(), + new float[255], + new PhysicsDataCache())); + } + + [Fact] + public void Build_ExceptionReleasesSharedGateForTheNextTransaction() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + proxy.ExceptionToThrow = new InvalidDataException("fixture read failure"); + var factory = Factory(dat, new object()); + + Assert.Throws(() => + factory.Build(Request(LandblockStreamJobKind.LoadFar))); + proxy.ExceptionToThrow = null; + + Assert.NotNull(factory.Build(Request(LandblockStreamJobKind.LoadFar))); + } + + [Fact] + public void FactorySurface_HasNoRendererWorldStateOrLiveOriginDependency() + { + Type type = typeof(LandblockBuildFactory); + Type[] dependencyTypes = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .Concat(type.GetConstructors().SelectMany(ctor => + ctor.GetParameters().Select(parameter => parameter.ParameterType))) + .ToArray(); + + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("GameWindow", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("GpuWorldState", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true); + } + + [Fact] + public void InstalledDatNearBuild_IsDeterministicAndClosesEntityPhysicsPayload() + { + string? datDirectory = ResolveDatDirectory(); + if (datDirectory is null) + throw SkipException.ForSkip("Installed retail DATs are required."); + + using var dat = new BoundedTestDatCollection(datDirectory); + var bounded = (IDatReaderWriter)dat; + Region? region = bounded.Get(0x13000000u); + float[]? heights = region?.LandDefs.LandHeightTable; + Assert.NotNull(heights); + Assert.True(heights.Length >= 256); + var factory = new LandblockBuildFactory( + bounded, + new object(), + heights, + new PhysicsDataCache()); + LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4); + + LandblockBuild? first = factory.Build(request); + LandblockBuild? second = factory.Build(request); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(HoltburgOrigin, first.Origin); + Assert.NotNull(first.EnvCells); + Assert.NotEmpty(first.Landblock.Entities); + AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities); + Assert.Equal( + first.EnvCells.VisibilityCells.Select(cell => cell.CellId), + second.EnvCells!.VisibilityCells.Select(cell => cell.CellId)); + Assert.Equal( + first.EnvCells.Shells.Select(shell => shell.GeometryId), + second.EnvCells.Shells.Select(shell => shell.GeometryId)); + + LandBlockInfo? info = bounded.Get( + (LandblockId & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + var expectedVisibilityCells = new List(); + uint firstCellId = (LandblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < info.NumCells; offset++) + { + uint cellId = firstCellId + offset; + EnvCell? cell = bounded.Get(cellId); + if (cell is null || cell.EnvironmentId == 0) + continue; + DatReaderWriter.DBObjs.Environment? environment = + bounded.Get( + 0x0D000000u | cell.EnvironmentId); + if (environment?.Cells.ContainsKey(cell.CellStructure) == true) + expectedVisibilityCells.Add(cellId); + } + Assert.Equal( + expectedVisibilityCells, + first.EnvCells.VisibilityCells.Select(cell => cell.CellId)); + + PhysicsDatBundle physics = Assert.IsType( + first.Landblock.PhysicsDats); + Assert.NotNull(physics.Info); + foreach (WorldEntity entity in first.Landblock.Entities) + { + if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + Assert.Contains(entity.SourceGfxObjOrSetupId, physics.Setups.Keys); + foreach (MeshRef mesh in entity.MeshRefs) + Assert.Contains(mesh.GfxObjId, physics.GfxObjs.Keys); + } + } + + private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) => + new(dat, gate, new float[256], new PhysicsDataCache()); + + private static LandblockBuildRequest Request( + LandblockStreamJobKind kind, + ulong generation = 1) => + new(LandblockId, kind, generation, HoltburgOrigin); + + private static LandblockBuildRequest Request( + uint landblockId, + LandblockStreamJobKind kind, + ulong generation, + LandblockBuildOrigin origin) => + new(landblockId, kind, generation, origin); + + private static void AddNearFixture( + RecordingDatProxy proxy, + uint landblockId, + ushort environmentId, + uint cellCount = 1) + { + proxy.Add(landblockId, new LandBlock { Id = landblockId }); + proxy.Add( + (landblockId & 0xFFFF0000u) | 0xFFFEu, + new LandBlockInfo { NumCells = cellCount }); + + var environment = new DatReaderWriter.DBObjs.Environment + { + Id = 0x0D000000u | environmentId, + }; + for (uint offset = 0; offset < cellCount; offset++) + { + ushort structure = checked((ushort)(offset + 1)); + uint cellId = (landblockId & 0xFFFF0000u) | (0x0100u + offset); + proxy.Add(cellId, new EnvCell + { + Id = cellId, + EnvironmentId = environmentId, + CellStructure = structure, + Position = new Frame + { + Orientation = Quaternion.Identity, + }, + }); + environment.Cells[structure] = EmptyCellStruct(); + } + proxy.Add(environment.Id, environment); + } + + private static CellStruct EmptyCellStruct() => new() + { + VertexArray = new VertexArray + { + Vertices = new Dictionary(), + }, + }; + + private static IDatReaderWriter CreateDat(out RecordingDatProxy proxy) + { + IDatReaderWriter dat = DispatchProxy.Create(); + proxy = (RecordingDatProxy)(object)dat; + return dat; + } + + private static void AssertEntityBuildsEqual( + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (int i = 0; i < expected.Count; i++) + { + WorldEntity left = expected[i]; + WorldEntity right = actual[i]; + Assert.Equal(left.Id, right.Id); + Assert.Equal(left.SourceGfxObjOrSetupId, right.SourceGfxObjOrSetupId); + Assert.Equal(left.Position, right.Position); + Assert.Equal(left.Rotation, right.Rotation); + Assert.Equal(left.ParentCellId, right.ParentCellId); + Assert.Equal(left.EffectCellId, right.EffectCellId); + Assert.Equal(left.MeshRefs.Count, right.MeshRefs.Count); + for (int part = 0; part < left.MeshRefs.Count; part++) + Assert.Equal(left.MeshRefs[part], right.MeshRefs[part]); + } + } + + private static string? ResolveDatDirectory() + { + string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) + return configured; + const string installed = @"C:\Turbine\Asheron's Call"; + if (Directory.Exists(installed)) + return installed; + string documents = Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(documents) ? documents : null; + } + + private class RecordingDatProxy : DispatchProxy + { + private readonly Dictionary<(Type Type, uint Id), object> _objects = new(); + private readonly object _gate = new(); + private int _activeReads; + + internal List<(Type Type, uint Id)> Reads { get; } = new(); + internal int MaxConcurrentReads { get; private set; } + internal TimeSpan ReadDelay { get; set; } + internal Exception? ExceptionToThrow { get; set; } + internal ManualResetEventSlim ReadObserved { get; } = new(); + + internal void Add(uint id, T value) where T : class => + _objects[(typeof(T), id)] = value; + + internal void Remove(uint id) where T : class => + _objects.Remove((typeof(T), id)); + + internal void ClearReads() + { + lock (_gate) + { + Reads.Clear(); + MaxConcurrentReads = 0; + } + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == "Get" && args is [uint id]) + { + Type type = targetMethod.ReturnType; + int active = Interlocked.Increment(ref _activeReads); + ReadObserved.Set(); + lock (_gate) + { + Reads.Add((type, id)); + MaxConcurrentReads = Math.Max(MaxConcurrentReads, active); + } + try + { + if (ReadDelay > TimeSpan.Zero) + Thread.Sleep(ReadDelay); + if (ExceptionToThrow is { } error) + throw error; + return _objects.TryGetValue((type, id), out object? value) + ? value + : null; + } + finally + { + Interlocked.Decrement(ref _activeReads); + } + } + + if (targetMethod?.ReturnType == typeof(void)) + return null; + if (targetMethod?.ReturnType.IsValueType == true) + return Activator.CreateInstance(targetMethod.ReturnType); + return null; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 63cc5a67..27518587 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -232,27 +232,45 @@ public sealed class LandblockBuildOriginTests } [Fact] - public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter() + public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter() { string root = FindRepoRoot(); - string source = File.ReadAllText(Path.Combine( + string gameWindowSource = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "GameWindow.cs")); - string buildSection = Slice( - source, - "private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(", - "private void AdvanceLandblockPresentationRetirement("); + string buildSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Streaming", + "LandblockBuildFactory.cs")); string publicationSection = Slice( - source, + gameWindowSource, "private void ApplyLoadedTerrainLocked(", "private void OnUpdate(double dt)"); - Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal); + Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildLandblockForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildSceneryEntitiesForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildInteriorEntitiesForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildPhysicsDatBundle", + gameWindowSource, + StringComparison.Ordinal); Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal); Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);