diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fa5e05d2..a3d1df73 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,49 @@ Copy this block when adding a new issue: --- +## #214 — Dungeon login could replace a complete EnvCell render with a partial second load + +**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate +**Severity:** HIGH +**Component:** streaming / EnvCell rendering / portal visibility + +**Description:** Logging directly into the starter dungeon could leave its room +shells untextured or absent after a relaunch even though the same dungeon had +rendered correctly before logout. + +**Root cause:** Two independent load jobs published through shared, process-wide +pending collections. The login spawn first pre-collapsed streaming onto +`0x8C04FFFF`, then an obsolete post-spawn `ForceReloadWindow` reset the collapse +and enqueued the same dungeon again. `EnvCellRenderer.RegisterCell` mutated a +live landblock's pending lists from the worker while `FinalizeLandblock` drained +and replaced them on the render thread; the global portal-visibility bag had the +same ownership flaw across landblocks. The captured failure committed 1,123 +shells, then the duplicate completion replaced them with its remaining 11. + +**Resolution:** A worker now builds one private, immutable +`EnvCellLandblockBuild` per `LandblockBuild`. That exact payload travels with its +streaming completion and is committed on the render thread to +`CellVisibility`, `EnvCellRenderer`, and the cell physics cache. There are no +shared pending cell/shell collections to drain or cross-contaminate. The login +path now calls `InitializeKnownLoginCenter`: sealed dungeons pre-collapse once; +outdoor spawns wait for the first correctly centered tick. The old forced reload +was removed because `StreamingReadinessGate` already prevents guessed-center +loads before the real player spawn. + +**Files:** `src/AcDream.App/Streaming/LandblockBuild.cs`; +`src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs`; +`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`; +`src/AcDream.App/Rendering/CellVisibility.cs`; +`src/AcDream.App/Rendering/GameWindow.cs`. + +**Research:** `docs/research/2026-07-13-envcell-landblock-transaction.md`. + +**Acceptance:** Log out inside the starter dungeon, relaunch, and enter the same +character. The dungeon is fully textured on its first frame, remains complete, +and the log contains exactly one login dungeon collapse with no second reload. + +--- + ## #213 — Retail client commands were sent to ACE as chat text **Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2a167f0c..7b633f0b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -394,6 +394,24 @@ bypass; no Scissor over-include). This is a **consolidation of existing machiner (`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case gate to mask a seam — that anti-pattern produced the patchwork. +### Streaming publication ownership + +The streaming worker may read dats and build CPU payloads, but it must not mutate +live render-frame registries. A near-tier job produces one `LandblockBuild` whose +optional `EnvCellLandblockBuild` owns the complete portal-cell and drawable-shell +sets for that landblock. The exact build object crosses the `LandblockStreamer` +completion channel. On the render thread, `StreamingController` applies that one +completion to `CellVisibility`, cell physics state, `EnvCellRenderer`, and finally +`GpuWorldState` during the same update-frame drain. + +This is the publication boundary: per-job builders are private and single-use; +completed payloads are immutable snapshots; live landblock stores are replaced as +complete units. Process-wide pending bags or renderer-owned pending lists are not +valid streaming seams because a duplicate load or a second landblock can drain or +replace state produced by another job. CPU mesh extraction may still be scheduled +onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes +partially hydrated cell membership or shell placement to the renderer. + ## Roadmap Model The old R1-R8 architecture sequence was a useful early refactor sketch, but it diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 9483ed79..66850130 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -183,7 +183,7 @@ src/AcDream.App/ │ ├── TextureCache.cs # (already exists) │ ├── ParticleRenderer.cs # (already exists) │ ├── Sky/ # (already exists) -│ ├── Wb/ # (already exists — WB seam) +│ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction │ └── Vfx/ # (already exists) ├── Net/ │ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect @@ -191,7 +191,7 @@ src/AcDream.App/ │ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation ├── Interaction/ │ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch -├── Streaming/ # (already exists) +├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion ├── Input/ # (already exists) ├── Audio/ # (already exists) └── Plugins/ # (already exists) diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index be58ad63..b45a7b07 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -26,6 +26,15 @@ our tree (see CLAUDE.md for the full breakdown): `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache, shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes. +**EnvCell streaming seam:** `EnvCellLandblockBuildBuilder` is an acdream-owned +adapter around the extracted WB rendering path. One streaming job privately builds +the complete portal-cell + shell-placement payload, and `EnvCellRenderer.CommitLandblock` +publishes that completed snapshot on the render thread. WB's geometry-id arithmetic +and mesh preparation remain unchanged; the transaction wrapper exists because +acdream streams asynchronously while the WB editor's manager owned its own loading +loop. Do not reintroduce worker-side `RegisterCell` calls or shared pending cell +collections. + `DatCollectionAdapter` bridges our `IDatCollection` to the `IDatReaderWriter` interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26 internal `_dats.*` call sites — above the 20-site inline-swap threshold). diff --git a/docs/research/2026-07-13-envcell-landblock-transaction.md b/docs/research/2026-07-13-envcell-landblock-transaction.md new file mode 100644 index 00000000..90d23f8e --- /dev/null +++ b/docs/research/2026-07-13-envcell-landblock-transaction.md @@ -0,0 +1,106 @@ +# EnvCell landblock transaction — dungeon-login texture loss + +## Symptom and captured evidence + +Logging the `lundberg` account directly into starter dungeon landblock +`0x8C04FFFF` produced an untextured/incomplete world after relaunch. The capture +in `logs/lundberg-dungeon-relaunch-20260713-182018.out.log` established the +ordering: + +```text +streaming: dungeon collapse -> 0x8C04FFFF +streaming: dungeon collapse -> 0x8C04FFFF +[late-register] ... 1123 already committed +[finalize-replace] ... DISCARDING 1123 committed instances, replacing with 11 pending +``` + +The old renderer diagnostics came from commit `0a38d934`. They proved that the +first job completed a full dungeon and a second job then overwrote it with the +small tail of a concurrently mutated pending list. This was not a texture decode +or GPU upload failure: the complete cell-shell set was discarded before draw. + +## Root causes + +There were two causes, both required for the observed deterministic failure: + +1. Commit `9b06a9b8` added a post-login `ForceReloadWindow` to recover from a + guessed-center streaming window. Commit `fa9aedca` later added + `StreamingReadinessGate`, which prevents that guessed window from being built + at all, but the force reload remained. A sealed-dungeon login therefore + pre-collapsed once during spawn and collapsed a second time after the forced + reset. +2. The streaming worker published partial state while it was still building. + `EnvCellRenderer.RegisterCell` appended to landblock-owned pending lists and a + global `_pendingCells` bag collected portal cells. The render thread drained + those mutable collections independently. Neither collection identified the + streaming job that produced an item, so duplicate and concurrent landblock + completions could consume or replace each other's state. + +The second cause is the architectural defect. Removing only the duplicate load +would make the current reproduction rarer, but leave the race available to +normal unload/reload and promotion timing. + +## Retail/WB boundary + +No retail gameplay algorithm changes here. The cell portal construction remains +the existing mechanical port of retail `PView::InitCell @ 0x005A4B70`, including +the dat `PortalSide` bit and reciprocal portal indexing. Geometry-id arithmetic +remains the verbatim WorldBuilder `EnvCellRenderManager.cs:94-103` port. + +This change is an acdream streaming ownership correction: retail does not expose +partially hydrated cell arrays to its draw traversal, while acdream must bridge a +background CPU loader to a single OpenGL/render thread. + +## Transaction design + +Worker-side pseudocode: + +```text +builder = new EnvCellLandblockBuildBuilder(landblockId) +for each EnvCell in this one landblock: + read dat cell + CellStruct + append visibility cell to builder-local storage + append drawable shell placement to builder-local storage +build = builder.Build() // one-way publication; immutable snapshot +schedule CPU mesh preparation(build) // queue only; no live renderer mutation +return LandblockBuild(terrain/entities, build) +``` + +Render-thread pseudocode: + +```text +completion = streamer.Drain() +validate completion.EnvCells belongs to completion.Landblock +replace this landblock's complete CellVisibility set +cache this landblock's cell physics structs +replace this landblock's complete EnvCellRenderer snapshot +publish the LoadedLandblock to GpuWorldState +``` + +The invariant is: **one streaming completion owns one complete EnvCell payload; +only the render thread may publish it to live per-frame state.** A later reload +may replace an earlier snapshot, but it can only replace it with another complete +snapshot from that exact job—never a shared-list remainder. + +## Login sequencing correction + +`StreamingController.InitializeKnownLoginCenter` now encodes the post-readiness- +gate behavior: + +- sealed dungeon: pre-collapse exactly once before the first normal tick; +- outdoor: enqueue nothing until the first tick at the server-confirmed center. + +The obsolete `_pendingForceReloadWindow` path was deleted. This removes the +duplicate dungeon job without adding a timing guard, retry, or suppression flag. + +## Regression coverage + +- A 1,123-shell build produces a 1,123-instance committed snapshot. +- Independently built 1,123- and 11-shell transactions cannot share storage. +- A builder can publish only once. +- Visibility replacement affects only the matching landblock and rejects a + foreign cell before mutating existing state. +- `LandblockStreamer` carries the exact `LandblockBuild`/EnvCell transaction + instance from worker to consumer. +- Repeated sealed-dungeon login initialization enqueues one collapse/load; an + outdoor login waits for its first correctly centered tick. diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index 7cabee61..035016b0 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -263,6 +263,32 @@ public sealed class CellVisibility _cellLookup[cell.CellId] = cell; } + /// + /// Atomically replaces one landblock's complete portal-cell set on the + /// render thread. Streaming workers build the input privately; no partially + /// hydrated landblock is ever visible to the per-frame flood. + /// + /// Full landblock id, e.g. 0xA9B4FFFF. + public void CommitLandblock(uint landblockId, IReadOnlyList cells) + { + uint prefix = landblockId >> 16; + if (cells.Any(cell => (cell.CellId >> 16) != prefix)) + throw new ArgumentException( + "A visibility cell belongs to a different landblock.", + nameof(cells)); + + if (_cellsByLandblock.TryGetValue(prefix, out var previous)) + { + foreach (var cell in previous) + _cellLookup.Remove(cell.CellId); + } + + var committed = new List(cells); + _cellsByLandblock[prefix] = committed; + foreach (var cell in committed) + _cellLookup[cell.CellId] = cell; + } + /// /// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a /// landblock prefix. Used by GameWindow.ApplyLoadedTerrainLocked when diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 27424c48..53824db3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -191,19 +191,6 @@ public sealed class GameWindow : IDisposable // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. private System.Collections.Concurrent.ConcurrentDictionary? _surfaceCache; - // Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes - // (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains - // this dict and uploads them via EnsureUploaded before the per-MeshRef loop. - // ConcurrentDictionary is required because the worker and render threads - // access this simultaneously without a broader lock. - private readonly System.Collections.Concurrent.ConcurrentDictionary< - uint, System.Collections.Generic.IReadOnlyList> - _pendingCellMeshes = new(); - - // Step 4: pending LoadedCell objects built on the worker thread, drained - // to _cellVisibility on the render thread in ApplyLoadedTerrain. - private readonly System.Collections.Concurrent.ConcurrentBag _pendingCells = new(); - // Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock // id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain // time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame @@ -991,17 +978,6 @@ public sealed class GameWindow : IDisposable // closes (stale-centered geometry built during the InWorld-but-not-yet- // located window, applied late once its build finished). private bool _liveCenterKnown; - // Set by the login-spawn recenter (network thread) when the spawn landblock - // differs from the startup streaming center; consumed on the render thread - // in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far - // login-spawn moves the render origin just like an outdoor teleport, so the - // streaming region — bootstrapped around the stale startup center — must be - // rebuilt fresh around the spawn. Without this, RecenterTo treats the - // old/new window overlap as already-resident (MarkResidentFromBootstrap marks - // residence before the async loads land), leaving a permanent hole of - // resident-but-never-loaded landblocks where the player spawns — the - // cold-spawn "invisible player / world not loading" bug (2026-07-03). - private volatile bool _pendingForceReloadWindow; private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id // K-fix1 (2026-04-26): cached at startup so per-frame branches are @@ -3182,13 +3158,6 @@ public sealed class GameWindow : IDisposable $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); _liveCenterX = lbX; _liveCenterY = lbY; - // The origin jumped — the streaming region is still bootstrapped - // around the stale startup center with residence marked before its - // async loads landed. Flag a full window rebuild (consumed on the - // render thread in OnUpdateFrame) so the region re-bootstraps fresh - // around the spawn, re-loading the landblocks RecenterTo would - // otherwise skip as already-resident — the cold-spawn streaming hole. - _pendingForceReloadWindow = true; } // #135: the instant we know the player spawned into a SEALED dungeon, @@ -3202,10 +3171,12 @@ public sealed class GameWindow : IDisposable // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it // (reentrant); the controller call is render-thread-safe (Channel writes). if (spawn.Guid == _playerServerGuid - && _streamingController is not null - && IsSealedDungeonCell(p.LandblockId)) + && _streamingController is not null) { - _streamingController.PreCollapseToDungeon(lbX, lbY); + _streamingController.InitializeKnownLoginCenter( + lbX, + lbY, + isSealedDungeon: IsSealedDungeonCell(p.LandblockId)); } var origin = new System.Numerics.Vector3( @@ -6453,7 +6424,7 @@ public sealed class GameWindow : IDisposable /// with an /// early-out at the source. /// - private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming( + private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming( uint landblockId, AcDream.App.Streaming.LandblockStreamJobKind kind) { @@ -6491,7 +6462,7 @@ public sealed class GameWindow : IDisposable } } - private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked( + private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked( uint landblockId, AcDream.App.Streaming.LandblockStreamJobKind kind) { @@ -6506,11 +6477,12 @@ public sealed class GameWindow : IDisposable { var heightmapOnly = _dats.Get(landblockId); if (heightmapOnly is null) return null; - return new AcDream.Core.World.LoadedLandblock( - landblockId, - heightmapOnly, - System.Array.Empty(), - AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities + return new AcDream.App.Streaming.LandblockBuild( + new AcDream.Core.World.LoadedLandblock( + landblockId, + heightmapOnly, + System.Array.Empty(), + AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities } var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); @@ -6587,17 +6559,27 @@ public sealed class GameWindow : IDisposable // Task 8: merge stabs + scenery + interior into one entity list. var merged = new List(hydrated); merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); - merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY)); + var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId); + merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, 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); - return new AcDream.Core.World.LoadedLandblock( - baseLoaded.LandblockId, - baseLoaded.Heightmap, - merged, - physicsDats); + var completedEnvCells = envCellBuild.Build(); + if (_wbMeshAdapter?.MeshManager is { } meshManager) + { + AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule( + completedEnvCells, + meshManager); + } + return new AcDream.App.Streaming.LandblockBuild( + new AcDream.Core.World.LoadedLandblock( + baseLoaded.LandblockId, + baseLoaded.Heightmap, + merged, + physicsDats), + completedEnvCells); } /// @@ -6898,13 +6880,18 @@ public sealed class GameWindow : IDisposable /// 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. /// - /// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary) - /// so ApplyLoadedTerrain can drain + upload them on the render thread. + /// 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) + uint landblockId, + int lbX, + int lbY, + AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild) { var result = new List(); if (_dats is null) return result; @@ -6976,12 +6963,12 @@ public sealed class GameWindow : IDisposable && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) { // Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as - // WorldEntities with fake MeshRefs. The CellMesh.Build call stays for - // _pendingCellMeshes (still populated for any consumer) and the physics - // data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes). + // 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.RegisterCell receives an empty staticObjects list. + // 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 @@ -7007,44 +6994,20 @@ public sealed class GameWindow : IDisposable // 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; BuildLoadedCell reads the cellStruct portals, NOT + // 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). - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - - // PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of - // drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer → - // sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to - // TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can - // never reach it and lags one cell behind the eye (#133 residual: the camera sat - // 1.32 m past the ramp portal's plane while the viewer cell stalled in - // 0x00070103 — the sweep transited every cached neighbour but NEVER the - // un-cached connector 0x014D — so the side test culled the on-screen connector - // portal and the grey clear showed through). Retail keeps the whole landblock - // cell array resident for the sweep; a portals-only connector has an empty - // collision BSP but its portals drive the transit. CacheCellStruct reads the - // cellStruct directly, not the render sub-meshes. - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); - var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); - if (cellSubMeshes.Count > 0) - { - _pendingCellMeshes[envCellId] = cellSubMeshes; - - // Phase A8: register the cell with EnvCellRenderer for rendering. - // staticObjects is empty — cell stabs continue as separate WorldEntity - // records via the dispatcher (see lines below for the unchanged stab path). - _envCellRenderer?.RegisterCell( - landblockId: landblockId, - envCellId: envCellId, - envCell: envCell, - cellStruct: cellStruct, - cellTransform: cellTransform, - cellWorldPosition: cellOrigin, - cellRotation: envCell.Position.Orientation, - staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - } + envCellBuild.AddCell( + envCellId, + envCell, + cellStruct, + physicsCellOrigin, + physicsCellTransform, + cellOrigin, + cellTransform, + hasDrawableGeometry: cellSubMeshes.Count > 0); } } @@ -7207,9 +7170,10 @@ public sealed class GameWindow : IDisposable /// this callback no longer pays that CPU cost. /// Must only be called from the render thread. /// - private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb, + private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build, AcDream.Core.Terrain.LandblockMeshData meshData) { + var lb = build.Landblock; if (_terrain is null || _dats is null) return; AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId); @@ -7227,7 +7191,7 @@ public sealed class GameWindow : IDisposable long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; if (_frameDiag) _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - ApplyLoadedTerrainLocked(lb, meshData); + ApplyLoadedTerrainLocked(build, meshData); if (_frameDiag) { _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; @@ -7235,164 +7199,11 @@ public sealed class GameWindow : IDisposable } } - /// - /// Step 4: build a for portal visibility and queue it - /// for render-thread registration. Called from the worker thread during - /// . - /// - private void BuildLoadedCell( - uint envCellId, - DatReaderWriter.DBObjs.EnvCell envCell, - DatReaderWriter.Types.CellStruct cellStruct, - System.Numerics.Vector3 cellOrigin, - System.Numerics.Matrix4x4 cellTransform) - { - System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse); - - // Compute local AABB from CellStruct vertices. - var boundsMin = new System.Numerics.Vector3(float.MaxValue); - var boundsMax = new System.Numerics.Vector3(float.MinValue); - foreach (var kvp in cellStruct.VertexArray.Vertices) - { - var v = kvp.Value; - var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); - boundsMin = System.Numerics.Vector3.Min(boundsMin, pos); - boundsMax = System.Numerics.Vector3.Max(boundsMax, pos); - } - if (boundsMin.X == float.MaxValue) - { - boundsMin = System.Numerics.Vector3.Zero; - boundsMax = System.Numerics.Vector3.Zero; - } - - // Build portal list and clip planes from CellPortals. - var portals = new List(); - var clipPlanes = new List(); - var portalPolygons = new List(); - - foreach (var portal in envCell.CellPortals) - { - portals.Add(new CellPortalInfo( - portal.OtherCellId, - portal.PolygonId, - (ushort)portal.Flags, - portal.OtherPortalId)); // Phase U.2b: dat back-link → reciprocal portal index (retail 433557) - - // Build clip plane from the portal polygon. - if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly) - && poly.VertexIds.Count >= 3) - { - // Get first 3 vertices in local space for the plane. - System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero, - p1 = System.Numerics.Vector3.Zero, - p2 = System.Numerics.Vector3.Zero; - bool found = true; - if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)) - p0 = new System.Numerics.Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z); - else found = false; - if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)) - p1 = new System.Numerics.Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z); - else found = false; - if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2)) - p2 = new System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); - else found = false; - - if (found) - { - var normal = System.Numerics.Vector3.Normalize( - System.Numerics.Vector3.Cross(p1 - p0, p2 - p0)); - float d = -System.Numerics.Vector3.Dot(normal, p0); - - // InsideSide from the dat PortalSide bit — retail PView::InitCell - // (0x005a4b70) gates traversal on `iVar9 != portals->portal_side`, and - // acdream's own physics path uses the same bit (CellTransit.cs:190). - // The former AABB-centroid reconstruction mis-derived the interior side - // for THIN connector cells: the connector's bounding-box center falls on - // the wrong side of a portal near the cell edge, so the eye read as a - // back-portal and the forward room was culled — the #186 grey flap at - // 0xF6820118→0116 (retail draws 0116 from that root; cdb-confirmed). - // PortalSide (Flags&2)==0 → the portal polygon normal points INTO the - // owning cell → interior is the negative half → InsideSide=1. Diagnostic - // Issue186…PortalSide_CentroidVsDatBit_AtGreyEye: the dat bit matches the - // centroid on every portal of these cells EXCEPT the one #186 breaks. - int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0; - - clipPlanes.Add(new PortalClipPlane - { - Normal = normal, - D = d, - InsideSide = insideSide, - }); - } - else - { - clipPlanes.Add(default); - } - } - else - { - clipPlanes.Add(default); - } - - // Phase A8: capture the full polygon vertices in cell-local space - // for the indoor stencil pipeline. Reads the same source as the - // ClipPlane block above (polygon.VertexIds → cellStruct vertices). - System.Numerics.Vector3[] polyVerts = System.Array.Empty(); - if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly) - && portalPoly.VertexIds.Count >= 3) - { - polyVerts = new System.Numerics.Vector3[portalPoly.VertexIds.Count]; - bool allResolved = true; - for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++) - { - if (cellStruct.VertexArray.Vertices.TryGetValue( - (ushort)portalPoly.VertexIds[vi], out var pv)) - { - polyVerts[vi] = new System.Numerics.Vector3( - pv.Origin.X, pv.Origin.Y, pv.Origin.Z); - } - else - { - allResolved = false; - break; - } - } - if (!allResolved) - polyVerts = System.Array.Empty(); - } - portalPolygons.Add(polyVerts); - } - - // Phase U.4c: surface the stable PVS + seen-outside flag onto the render cell. - // Both come straight off the dat EnvCell — no new parsing (PhysicsDataCache - // already reads VisibleCells the same way; A8CellAudit reads the flag). - uint lbPrefix = envCellId & 0xFFFF0000u; - var visibleCells = new List(); - if (envCell.VisibleCells is not null) - foreach (var lowId in envCell.VisibleCells) - visibleCells.Add(lbPrefix | lowId); - bool seenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); - - var loaded = new LoadedCell - { - CellId = envCellId, - WorldPosition = cellOrigin, - WorldTransform = cellTransform, - InverseWorldTransform = inverse, - LocalBoundsMin = boundsMin, - LocalBoundsMax = boundsMax, - Portals = portals, - ClipPlanes = clipPlanes, - PortalPolygons = portalPolygons, // Phase A8 - VisibleCells = visibleCells, // Phase U.4c - SeenOutside = seenOutside, // Phase U.4c - }; - _pendingCells.Add(loaded); - } - - private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb, + private void ApplyLoadedTerrainLocked( + AcDream.App.Streaming.LandblockBuild build, AcDream.Core.Terrain.LandblockMeshData meshData) { + var lb = build.Landblock; // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). // _heightTable still needed for physics TerrainSurface below. if (_terrain is null || _dats is null || _heightTable is null) return; @@ -7425,13 +7236,20 @@ public sealed class GameWindow : IDisposable // method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights. long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - // Step 4: drain pending LoadedCells from the worker thread. - // Also collect into a local dict for the BuildingLoader stamping pass below. - var drainedCells = new System.Collections.Generic.Dictionary(); - while (_pendingCells.TryTake(out var cell)) + // Commit the exact visibility-cell snapshot produced by THIS streaming + // completion. The former global ConcurrentBag let one landblock drain + // another landblock's cells and exposed partial builds to the flood. + var committedCells = new System.Collections.Generic.Dictionary(); + if (build.EnvCells is { } envCellBuild) { - _cellVisibility.AddCell(cell); - drainedCells[cell.CellId] = cell; + if (envCellBuild.LandblockId != lb.LandblockId) + throw new InvalidOperationException( + $"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " + + $"landblock 0x{lb.LandblockId:X8}."); + + _cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells); + foreach (var cell in envCellBuild.VisibilityCells) + committedCells[cell.CellId] = cell; } // Compute the per-landblock AABB for frustum culling. XY from the @@ -7493,6 +7311,19 @@ public sealed class GameWindow : IDisposable // Transform CellStruct vertices from cell-local to world space. var rot = envCell.Position.Orientation; var cellOriginWorld = envCell.Position.Origin + origin; + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(rot) * + System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld); + + // Commit live physics-cache state on the render thread with + // the same completed landblock transaction. The worker only + // builds private output; it no longer mutates cell registries. + _physicsDataCache.CacheCellStruct( + envCellId, + envCell, + cellStruct, + physicsCellTransform); + var worldVerts = new Dictionary( cellStruct.VertexArray.Vertices.Count); foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices) @@ -7655,16 +7486,9 @@ public sealed class GameWindow : IDisposable // surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings). if (lbInfo is not null) { - // Merge: previously-loaded cells + freshly-drained cells. - // drainedCells wins on key collision (it has the same instance - // anyway — both dicts reference the same LoadedCell objects). - var lbStampCells = new System.Collections.Generic.Dictionary(drainedCells); - uint lbPrefix = (lb.LandblockId >> 16) & 0xFFFFu; - foreach (var c in _cellVisibility.GetCellsForLandblock(lbPrefix)) - { - if (!lbStampCells.ContainsKey(c.CellId)) - lbStampCells[c.CellId] = c; - } + // The streaming result now carries the complete landblock cell + // set; no cross-frame merge or global-bag recovery is required. + var lbStampCells = committedCells; // FIX 2026-05-28: normalize storage key to the cell-prefix // convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId // field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the @@ -7681,15 +7505,13 @@ public sealed class GameWindow : IDisposable lbInfo, lb.LandblockId, lbStampCells); } - // Phase A8: finalize EnvCellRenderer's per-landblock instance store. - // Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds, - // populates StaticPartGroups/BuildingPartGroups via PopulateRecursive. - _envCellRenderer?.FinalizeLandblock(lb.LandblockId); + if (build.EnvCells is { } renderBuild) + _envCellRenderer?.CommitLandblock(renderBuild); } // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via // ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop - // (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer. + // and the legacy cell-mesh upload path are retired with InstancedMeshRenderer. // Cache GfxObj physics data (BSP trees) for the physics engine — this // loop is physics-only, not renderer-side. // [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP. @@ -7703,10 +7525,6 @@ public sealed class GameWindow : IDisposable _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); } } - // Drain _pendingCellMeshes to prevent unbounded accumulation. - // The data is no longer consumed (WB handles EnvCell geometry through - // its own pipeline), but the worker thread still populates this dict. - _pendingCellMeshes.Clear(); // [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights. if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; } @@ -8202,17 +8020,6 @@ public sealed class GameWindow : IDisposable observerCx = (int)((cellLb >> 8) & 0xFFu); observerCy = (int)(cellLb & 0xFFu); } - // Consume the login-spawn far-recenter flag (network thread → render - // thread): drop the stale startup window + null the region so this - // Tick re-bootstraps the whole window fresh around the spawn origin. - // Same mechanism the outdoor-teleport path uses (line ~5725). Fixes - // the cold-spawn streaming hole (resident-but-never-loaded landblocks - // the RecenterTo diff skipped as already-resident). - if (_pendingForceReloadWindow) - { - _pendingForceReloadWindow = false; - _streamingController.ForceReloadWindow(); - } _streamingController.Tick(observerCx, observerCy, insideDungeon); // Re-inject persistent entities rescued from unloaded landblocks @@ -13100,7 +12907,7 @@ public sealed class GameWindow : IDisposable // a real dungeon (collapse streaming to its single landblock) from a building // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as - // the hydration path (BuildLoadedCell, ~line 5999) and as the physics + // the hydration path (EnvCellLandblockBuildBuilder) and as the physics // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse // decision matches the eventual gate decision exactly. Returns false when the dat // lacks the cell (out-of-range index / missing record) so we never collapse on a diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 38f263b8..c1dc2d85 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -188,7 +188,7 @@ public static class PortalVisibilityBuilder // portal, the exact inputs the BFS guards read — BEFORE the guards run, so // a portal the loop silently `continue`s past is still visible here. An // empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty - // polygon from BuildLoadedCell), interiorSide=false (camera back-facing the + // polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the // portal — a legitimately-empty result, not a bug), or (if both OK) a // downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines. for (int ci = 0; ci < cameraCell.Portals.Count; ci++) diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs new file mode 100644 index 00000000..36d3878a --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs @@ -0,0 +1,299 @@ +using System.Collections.Immutable; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering.Wb; + +/// +/// One drawable EnvCell shell prepared by the streaming worker. This is CPU-only +/// placement data; the render thread schedules mesh preparation when it commits +/// the containing . +/// +public sealed record EnvCellShellPlacement( + uint CellId, + ulong GeometryId, + uint EnvironmentId, + ushort CellStructure, + ImmutableArray Surfaces, + Vector3 WorldPosition, + Quaternion Rotation, + Matrix4x4 Transform, + WbBoundingBox LocalBounds, + WbBoundingBox WorldBounds); + +/// +/// Complete, immutable worker output for one landblock's indoor cells. It owns +/// both portal-visibility cells and drawable shell placements so neither can be +/// drained by, or mixed with, another streaming completion. +/// +public sealed class EnvCellLandblockBuild +{ + public EnvCellLandblockBuild( + uint landblockId, + IEnumerable visibilityCells, + IEnumerable shells) + { + LandblockId = landblockId; + VisibilityCells = visibilityCells.ToImmutableArray(); + Shells = shells.ToImmutableArray(); + + uint expectedPrefix = landblockId & 0xFFFF0000u; + if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix)) + throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells)); + if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix)) + throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells)); + } + + public uint LandblockId { get; } + public ImmutableArray VisibilityCells { get; } + public ImmutableArray Shells { get; } +} + +/// +/// Transaction-local builder used only by one streaming job. Unlike the former +/// global pending bags, instances of this class are never shared between jobs or +/// observed by the render thread before returns. +/// +public sealed class EnvCellLandblockBuildBuilder +{ + private readonly uint _landblockId; + private readonly List _visibilityCells = new(); + private readonly List _shells = new(); + private bool _built; + + public EnvCellLandblockBuildBuilder(uint landblockId) + { + _landblockId = landblockId; + } + + public void AddCell( + uint envCellId, + EnvCell envCell, + CellStruct cellStruct, + Vector3 physicsCellOrigin, + Matrix4x4 physicsCellTransform, + Vector3 shellWorldPosition, + Matrix4x4 shellTransform, + bool hasDrawableGeometry) + { + if (_built) + throw new InvalidOperationException("This landblock cell build is already complete."); + + var localBounds = ComputeLocalBounds(cellStruct); + _visibilityCells.Add(BuildVisibilityCell( + envCellId, + envCell, + cellStruct, + physicsCellOrigin, + physicsCellTransform, + localBounds)); + + if (!hasDrawableGeometry) + return; + + ulong geometryId = ComputeGeometryId( + envCell.EnvironmentId, + envCell.CellStructure, + envCell.Surfaces); + _shells.Add(new EnvCellShellPlacement( + envCellId, + geometryId, + envCell.EnvironmentId, + envCell.CellStructure, + envCell.Surfaces.ToImmutableArray(), + shellWorldPosition, + envCell.Position.Orientation, + shellTransform, + localBounds, + TransformBoundingBox(localBounds, shellTransform))); + } + + public EnvCellLandblockBuild Build() + { + if (_built) + throw new InvalidOperationException("This landblock cell build is already complete."); + _built = true; + return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells); + } + + /// + /// Source: WB EnvCellRenderManager.cs:94-103 (verbatim arithmetic). + /// + public static ulong ComputeGeometryId( + uint environmentId, + ushort cellStructure, + IReadOnlyList surfaces) + { + var hash = 17L; + hash = hash * 31 + (int)environmentId; + hash = hash * 31 + cellStructure; + foreach (var surface in surfaces) + hash = hash * 31 + surface; + return (ulong)hash | 0x2_0000_0000UL; + } + + private static WbBoundingBox ComputeLocalBounds(CellStruct cellStruct) + { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vertex in cellStruct.VertexArray.Vertices.Values) + { + var position = new Vector3(vertex.Origin.X, vertex.Origin.Y, vertex.Origin.Z); + min = Vector3.Min(min, position); + max = Vector3.Max(max, position); + } + + return min.X == float.MaxValue + ? new WbBoundingBox(Vector3.Zero, Vector3.Zero) + : new WbBoundingBox(min, max); + } + + private static WbBoundingBox TransformBoundingBox(WbBoundingBox box, Matrix4x4 transform) + { + Span corners = stackalloc Vector3[8] + { + new(box.Min.X, box.Min.Y, box.Min.Z), + new(box.Max.X, box.Min.Y, box.Min.Z), + new(box.Min.X, box.Max.Y, box.Min.Z), + new(box.Max.X, box.Max.Y, box.Min.Z), + new(box.Min.X, box.Min.Y, box.Max.Z), + new(box.Max.X, box.Min.Y, box.Max.Z), + new(box.Min.X, box.Max.Y, box.Max.Z), + new(box.Max.X, box.Max.Y, box.Max.Z), + }; + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var corner in corners) + { + var transformed = Vector3.Transform(corner, transform); + min = Vector3.Min(min, transformed); + max = Vector3.Max(max, transformed); + } + + return new WbBoundingBox(min, max); + } + + /// + /// Mechanical extraction of the former GameWindow.BuildLoadedCell. Portal-side behavior + /// is unchanged: retail PView::InitCell (0x005a4b70) uses the dat PortalSide + /// bit, and the reciprocal portal index follows the named retail path at + /// pseudo-C line 433557. + /// + private static LoadedCell BuildVisibilityCell( + uint envCellId, + EnvCell envCell, + CellStruct cellStruct, + Vector3 cellOrigin, + Matrix4x4 cellTransform, + WbBoundingBox localBounds) + { + Matrix4x4.Invert(cellTransform, out var inverse); + + var portals = new List(); + var clipPlanes = new List(); + var portalPolygons = new List(); + + foreach (var portal in envCell.CellPortals) + { + portals.Add(new CellPortalInfo( + portal.OtherCellId, + portal.PolygonId, + (ushort)portal.Flags, + portal.OtherPortalId)); + + if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly) + && poly.VertexIds.Count >= 3) + { + Vector3 p0 = Vector3.Zero, p1 = Vector3.Zero, p2 = Vector3.Zero; + bool found = true; + if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)) + p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z); + else + found = false; + if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)) + p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z); + else + found = false; + if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2)) + p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); + else + found = false; + + if (found) + { + var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); + float d = -Vector3.Dot(normal, p0); + int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0; + clipPlanes.Add(new PortalClipPlane + { + Normal = normal, + D = d, + InsideSide = insideSide, + }); + } + else + { + clipPlanes.Add(default); + } + } + else + { + clipPlanes.Add(default); + } + + Vector3[] polygonVertices = Array.Empty(); + if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPolygon) + && portalPolygon.VertexIds.Count >= 3) + { + polygonVertices = new Vector3[portalPolygon.VertexIds.Count]; + bool allResolved = true; + for (int vertexIndex = 0; vertexIndex < portalPolygon.VertexIds.Count; vertexIndex++) + { + if (cellStruct.VertexArray.Vertices.TryGetValue( + (ushort)portalPolygon.VertexIds[vertexIndex], out var vertex)) + { + polygonVertices[vertexIndex] = new Vector3( + vertex.Origin.X, + vertex.Origin.Y, + vertex.Origin.Z); + } + else + { + allResolved = false; + break; + } + } + + if (!allResolved) + polygonVertices = Array.Empty(); + } + portalPolygons.Add(polygonVertices); + } + + uint landblockPrefix = envCellId & 0xFFFF0000u; + var visibleCells = new List(); + if (envCell.VisibleCells is not null) + { + foreach (var lowId in envCell.VisibleCells) + visibleCells.Add(landblockPrefix | lowId); + } + + return new LoadedCell + { + CellId = envCellId, + WorldPosition = cellOrigin, + WorldTransform = cellTransform, + InverseWorldTransform = inverse, + LocalBoundsMin = localBounds.Min, + LocalBoundsMax = localBounds.Max, + Portals = portals, + ClipPlanes = clipPlanes, + PortalPolygons = portalPolygons, + VisibleCells = visibleCells, + SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside), + }; + } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs new file mode 100644 index 00000000..f8fe92e1 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs @@ -0,0 +1,23 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Starts CPU mesh extraction for a completed EnvCell build without publishing +/// that build to the live renderer. ObjectMeshManager owns its thread-safe work +/// queue; the render-thread commit remains a small atomic state replacement. +/// +public static class EnvCellMeshPreparationScheduler +{ + public static void Schedule( + EnvCellLandblockBuild build, + ObjectMeshManager meshManager) + { + foreach (var shell in build.Shells) + { + _ = meshManager.PrepareEnvCellGeomMeshDataAsync( + shell.GeometryId, + shell.EnvironmentId, + shell.CellStructure, + new List(shell.Surfaces)); + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index e8d454a4..6be8d96d 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -8,15 +8,14 @@ // PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373 // Render(filter:) <- WB EnvCellRenderManager.cs:395-511 // RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant) -// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580 // AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393 // // Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base // class owns the landblock-streaming loop (Update, _pendingGeneration, // _uploadQueue). acdream's StreamingController already does that work — -// running a parallel loop would compete for dat I/O. Instead, we expose -// RegisterCell(...) as the seam: callers populate our instance store at -// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming). +// running a parallel loop would compete for dat I/O. Instead, streaming builds +// a private EnvCellLandblockBuild and CommitLandblock publishes the completed +// snapshot on the render thread. using System; using System.Collections.Concurrent; @@ -36,7 +35,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable private readonly ObjectMeshManager _meshManager; private readonly WbFrustum _frustum; - // Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000). + // Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF). // WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary _landblocks — // we use uint (full LB id) because acdream uses 32-bit landblock keys throughout. private readonly ConcurrentDictionary _landblocks = new(); @@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable /// Source: WB EnvCellRenderManager.cs:94-103 (verbatim). /// public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List surfaces) - { - // WB EnvCellRenderManager.cs:95-102 verbatim: - var hash = 17L; - hash = hash * 31 + (int)environmentId; - hash = hash * 31 + cellStructure; - foreach (var surface in surfaces) { - hash = hash * 31 + surface; - } - // Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry) - return (ulong)hash | 0x2_0000_0000UL; - } + => EnvCellLandblockBuildBuilder.ComputeGeometryId( + environmentId, + cellStructure, + surfaces); // --------------------------------------------------------------------------- - // RegisterCell — streaming seam - // Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load - // time, ONCE per EnvCell that has non-null EnvironmentId. + // CommitLandblock — render-thread transaction boundary // --------------------------------------------------------------------------- /// - /// Registers a single EnvCell and its static objects into the per-landblock - /// pending list. Call after all cells for a - /// landblock have been registered. + /// Commits one complete worker-built landblock snapshot. A replacement is + /// constructed off to the side, then published with one dictionary write; + /// render preparation can observe either the previous complete snapshot or + /// this complete snapshot, never an in-progress cell list. /// - public void RegisterCell( - uint landblockId, - uint envCellId, - DatReaderWriter.DBObjs.EnvCell envCell, - DatReaderWriter.Types.CellStruct cellStruct, - Matrix4x4 cellTransform, - Vector3 cellWorldPosition, - Quaternion cellRotation, - IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects) + public void CommitLandblock(EnvCellLandblockBuild build) { - // 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId). - var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces); + _landblocks[build.LandblockId] = CreateCommittedSnapshot(build); + NeedsPrepare = true; + } - // 2. Trigger mesh prep for the cell geometry on ObjectMeshManager. - // This populates ObjectMeshManager._renderData[cellGeomId] when complete. - // Verified Phase W Stage 4 (T4.4): ceilings are present by construction. - // PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons - // (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO - // surface filter — every polygon that passes the ≥3-vertex check is added to - // the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which - // is the same closed-box structure. No ceiling polygons are dropped. - _ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId, - envCell.CellStructure, envCell.Surfaces); - - // 3. Compute local bounds from cellStruct vertex array. - var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct); - var worldBounds = TransformBoundingBox(localBounds, cellTransform); - - // 4. Create the per-cell SceneryInstance. - var cellInstance = new EnvCellSceneryInstance + /// + /// Pure half of . Kept separate so transaction + /// replacement semantics are regression-tested without an OpenGL context. + /// + internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build) + { + var replacement = new EnvCellLandblock { - ObjectId = cellGeomId, - InstanceId = envCellId, // uint InstanceId maps to the cell id - IsBuilding = true, - IsEntryCell = false, // could be derived from entry-portal walk; default false - WorldPosition = cellWorldPosition, - LocalPosition = Vector3.Zero, - Rotation = cellRotation, - Scale = Vector3.One, - Transform = cellTransform, - LocalBoundingBox = localBounds, - BoundingBox = worldBounds, + GridX = (int)((build.LandblockId >> 24) & 0xFFu), + GridY = (int)((build.LandblockId >> 16) & 0xFFu), }; - var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock + foreach (var shell in build.Shells) { - GridX = (int)((id >> 24) & 0xFFu), - GridY = (int)((id >> 16) & 0xFFu), - }); - - lock (lb.Lock) - { - // TEMP diagnostic #105 (strip with fix): a registration landing AFTER - // this landblock was already finalized starts a fresh pending list that - // only commits if ANOTHER finalize arrives — and that one will REPLACE - // (not merge) the committed set. One-shot per landblock per pending list. - if (lb.InstancesReady && lb.PendingInstances is null) - Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)"); - lb.PendingInstances ??= new List(capacity: 32); - lb.PendingInstances.Add(cellInstance); - lb.PendingEnvCellBounds ??= new Dictionary(); - lb.PendingEnvCellBounds[envCellId] = worldBounds; - - // Add static-object instances inside the cell. - foreach (var stab in staticObjects) + replacement.Instances.Add(new EnvCellSceneryInstance { - // Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes). - _ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup); + ObjectId = shell.GeometryId, + InstanceId = shell.CellId, + IsBuilding = true, + IsEntryCell = false, + WorldPosition = shell.WorldPosition, + LocalPosition = Vector3.Zero, + Rotation = shell.Rotation, + Scale = Vector3.One, + Transform = shell.Transform, + LocalBoundingBox = shell.LocalBounds, + BoundingBox = shell.WorldBounds, + }); + replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds; - WbBoundingBox stabBoundsLocal; - var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup); - if (rawBounds.HasValue) - stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max); - else - stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero); - - var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform); - - lb.PendingInstances.Add(new EnvCellSceneryInstance - { - ObjectId = stab.StaticObjectId, - InstanceId = 0, - IsSetup = stab.IsSetup, - IsBuilding = false, - Transform = stab.Transform, - BoundingBox = stabBoundsWorld, - LocalBoundingBox = stabBoundsLocal, - Scale = Vector3.One, - Rotation = stab.Rot, - // CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups - CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch - }); - - // Union the cell bounds with the stab bounds. - var current = lb.PendingEnvCellBounds[envCellId]; - lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld); + if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances)) + { + instances = new List(); + replacement.BuildingPartGroups[shell.GeometryId] = instances; } + instances.Add(new InstanceData + { + Transform = shell.Transform, + CellId = shell.CellId, + Flags = 0, + }); } - } - /// - /// Atomically promotes the pending instance list to the committed list, - /// computes total EnvCell bounds, populates PartGroups, and marks the - /// landblock ready for rendering. - /// - public void FinalizeLandblock(uint landblockId) - { - if (!_landblocks.TryGetValue(landblockId, out var lb)) return; - lock (lb.Lock) - { - if (lb.PendingInstances is not null) - { - // TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a - // previous finalize already committed instances for this landblock, - // this swap DISCARDS them in favor of the new pending set. A partial - // pending set (finalize racing a still-registering promote job) - // silently loses buildings. - if (lb.Instances is { Count: > 0 }) - Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending"); - lb.Instances = lb.PendingInstances; - lb.PendingInstances = null; - } - if (lb.PendingEnvCellBounds is not null) - { - lb.EnvCellBounds = lb.PendingEnvCellBounds; - lb.PendingEnvCellBounds = null; - } + var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); + foreach (var bounds in replacement.EnvCellBounds.Values) + total = WbBoundingBox.Union(total, bounds); + replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0 + ? new WbBoundingBox(Vector3.Zero, Vector3.Zero) + : total; - // Compute total bounds union across all cells. - var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); - foreach (var b in lb.EnvCellBounds.Values) - total = WbBoundingBox.Union(total, b); - lb.TotalEnvCellBounds = total; - - // Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580). - PopulatePartGroups(lb); - - lb.InstancesReady = true; - lb.MeshDataReady = true; - lb.GpuReady = true; - } - NeedsPrepare = true; + replacement.InstancesReady = true; + replacement.MeshDataReady = true; + replacement.GpuReady = true; + return replacement; } /// @@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable NeedsPrepare = true; } - // --------------------------------------------------------------------------- - // PopulatePartGroups - // Verbatim port of WB EnvCellRenderManager.cs:572-580. - // --------------------------------------------------------------------------- - - /// - /// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list. - /// Must be called under lb.Lock. - /// Source: WB EnvCellRenderManager.cs:572-580 (verbatim). - /// - private void PopulatePartGroups(EnvCellLandblock lb) - { - // WB EnvCellRenderManager.cs:573-574: - lb.StaticPartGroups.Clear(); - lb.BuildingPartGroups.Clear(); - - // WB EnvCellRenderManager.cs:575-579: - foreach (var instance in lb.Instances) - { - var targetGroup = instance.IsBuilding ? lb.BuildingPartGroups : lb.StaticPartGroups; - // WB: var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId.DataId; - // We store the parent cellId in CurrentPreviewCellId (for stabs) or InstanceId (for cell geometry). - var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId; - PopulateRecursive(targetGroup, instance.ObjectId, instance.IsSetup, instance.Transform, cellId); - } - } - - /// - /// Recursively walks a Setup's parts (or handles a plain GfxObj directly), - /// adding one per leaf GfxObj to the target group. - /// Mirrors WB ObjectRenderManagerBase.PopulateRecursive. - /// - private void PopulateRecursive( - Dictionary> group, - ulong objectId, - bool isSetup, - Matrix4x4 transform, - uint cellId) - { - if (isSetup) - { - // For a Setup, TryGetRenderData returns a record whose SetupParts lists - // (partId, partTransform) pairs. We recursively handle each part. - var rd = _meshManager.TryGetRenderData(objectId); - if (rd is null || !rd.IsSetup) return; - - foreach (var (partId, partTransform) in rd.SetupParts) - { - // Compose: world = partTransform (relative to setup) * setup world transform. - // - // FIX 2026-05-28: detect nested Setups via the high-byte - // 0x02 convention (Setup IDs start with 0x02; plain GfxObj - // IDs start with 0x01). Mirrors WB - // ObjectRenderManagerBase.cs:813. Original port hardcoded - // isSetup:false which silently dropped any nested Setup — - // its TryGetRenderData returns IsSetup=true, and Render's - // `if (!renderData.IsSetup)` guard then skips the draw. - var combined = partTransform * transform; - bool partIsSetup = (partId >> 24) == 0x02UL; - PopulateRecursive(group, partId, partIsSetup, combined, cellId); - } - } - else - { - // Plain GfxObj — just add one InstanceData. - if (!group.TryGetValue(objectId, out var list)) - { - list = new List(); - group[objectId] = list; - } - list.Add(new InstanceData - { - Transform = transform, - CellId = cellId, - Flags = 0, - }); - } - } - // --------------------------------------------------------------------------- // PrepareRenderBatches // Verbatim port of WB EnvCellRenderManager.cs:247-373. @@ -1629,59 +1459,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable // Helpers: bounds computation // --------------------------------------------------------------------------- - /// - /// Computes a local-space AABB from a CellStruct's vertex array. - /// - private static WbBoundingBox ComputeLocalBoundsFromCellStruct(DatReaderWriter.Types.CellStruct cellStruct) - { - if (cellStruct.VertexArray is null || cellStruct.VertexArray.Vertices is null || cellStruct.VertexArray.Vertices.Count == 0) - return new WbBoundingBox(Vector3.Zero, Vector3.Zero); - - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - foreach (var v in cellStruct.VertexArray.Vertices.Values) - { - var p = v.Origin; - min = Vector3.Min(min, p); - max = Vector3.Max(max, p); - } - return new WbBoundingBox(min, max); - } - - /// - /// Transforms a local AABB to a world AABB by transforming all 8 corners. - /// - private static WbBoundingBox TransformBoundingBox(WbBoundingBox local, Matrix4x4 transform) - { - if (local.Min == local.Max && local.Min == Vector3.Zero) - return local; - - var min = local.Min; - var max = local.Max; - - Span corners = stackalloc Vector3[8] - { - new Vector3(min.X, min.Y, min.Z), - new Vector3(max.X, min.Y, min.Z), - new Vector3(min.X, max.Y, min.Z), - new Vector3(max.X, max.Y, min.Z), - new Vector3(min.X, min.Y, max.Z), - new Vector3(max.X, min.Y, max.Z), - new Vector3(min.X, max.Y, max.Z), - new Vector3(max.X, max.Y, max.Z), - }; - - var wMin = new Vector3(float.MaxValue); - var wMax = new Vector3(float.MinValue); - foreach (var c in corners) - { - var w = Vector3.Transform(c, transform); - wMin = Vector3.Min(wMin, w); - wMax = Vector3.Max(wMax, w); - } - return new WbBoundingBox(wMin, wMax); - } - // --------------------------------------------------------------------------- // IDisposable // --------------------------------------------------------------------------- diff --git a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs index 43749632..55f0b34f 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs @@ -90,18 +90,6 @@ public class EnvCellLandblock /// public HashSet SeenOutsideCells { get; set; } = new(); - public List? PendingInstances { get; set; } - - /// - /// Grouped bounding boxes for each EnvCell in this landblock (pending upload). - /// - public Dictionary? PendingEnvCellBounds { get; set; } - - /// - /// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload). - /// - public HashSet? PendingSeenOutsideCells { get; set; } - /// /// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering. /// Key: GfxObjId, Value: List of transforms @@ -124,11 +112,6 @@ public class EnvCellLandblock /// public WbBoundingBox TotalEnvCellBounds { get; set; } - /// - /// Total bounding box covering all EnvCells in this landblock (pending upload). - /// - public WbBoundingBox PendingTotalEnvCellBounds { get; set; } - /// /// Whether instances (positions/bounding boxes) have been generated. /// diff --git a/src/AcDream.App/Streaming/LandblockBuild.cs b/src/AcDream.App/Streaming/LandblockBuild.cs new file mode 100644 index 00000000..0f4485de --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockBuild.cs @@ -0,0 +1,16 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Complete CPU-side output of one streaming job. The worker owns this object +/// until it posts the corresponding completion; the render thread then applies +/// the landblock and its cell transaction together. +/// +public sealed record LandblockBuild( + LoadedLandblock Landblock, + EnvCellLandblockBuild? EnvCells = null) +{ + public uint LandblockId => Landblock.LandblockId; +} diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index 050c1265..ac83cec5 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -42,9 +42,21 @@ public abstract record LandblockStreamResult(uint LandblockId) public sealed record Loaded( uint LandblockId, LandblockStreamTier Tier, - LoadedLandblock Landblock, + LandblockBuild Build, LandblockMeshData MeshData - ) : LandblockStreamResult(LandblockId); + ) : LandblockStreamResult(LandblockId) + { + public Loaded( + uint landblockId, + LandblockStreamTier tier, + LoadedLandblock landblock, + LandblockMeshData meshData) + : this(landblockId, tier, new LandblockBuild(landblock), meshData) + { + } + + public LoadedLandblock Landblock => Build.Landblock; + } /// /// A previously-Far-resident landblock was promoted to Near. The result @@ -56,10 +68,19 @@ public abstract record LandblockStreamResult(uint LandblockId) /// public sealed record Promoted( uint LandblockId, - LoadedLandblock Landblock, + LandblockBuild Build, LandblockMeshData MeshData ) : LandblockStreamResult(LandblockId) { + public Promoted( + uint landblockId, + LoadedLandblock landblock, + LandblockMeshData meshData) + : this(landblockId, new LandblockBuild(landblock), meshData) + { + } + + public LoadedLandblock Landblock => Build.Landblock; public IReadOnlyList Entities => Landblock.Entities; } diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f5602b12..b66ce857 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -52,7 +52,7 @@ public sealed class LandblockStreamer : IDisposable /// public const int DefaultDrainBatchSize = 4; - private readonly Func _loadLandblock; + private readonly Func _loadLandblock; private readonly Func _buildMeshOrNull; private readonly Channel _inbox; private readonly Channel _outbox; @@ -68,7 +68,7 @@ public sealed class LandblockStreamer : IDisposable /// LandBlockInfo + SceneryGenerator work. /// public LandblockStreamer( - Func loadLandblock, + Func loadLandblock, Func? buildMeshOrNull = null) { _loadLandblock = loadLandblock; @@ -81,6 +81,22 @@ public sealed class LandblockStreamer : IDisposable new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); } + /// + /// Compatibility constructor for loaders that have no App-owned cell payload. + /// Production uses the overload so render state + /// remains attached to the exact streaming completion that produced it. + /// + public LandblockStreamer( + Func loadLandblock, + Func? buildMeshOrNull = null) + : this( + (id, kind) => loadLandblock(id, kind) is { } landblock + ? new LandblockBuild(landblock) + : null, + buildMeshOrNull) + { + } + /// /// Back-compat overload — wraps a kind-agnostic factory so existing test code /// that doesn't care about the JobKind branch keeps compiling. The wrapper @@ -90,7 +106,11 @@ public sealed class LandblockStreamer : IDisposable public LandblockStreamer( Func loadLandblock, Func? buildMeshOrNull = null) - : this((id, _) => loadLandblock(id), buildMeshOrNull) + : this( + (id, _) => loadLandblock(id) is { } landblock + ? new LandblockBuild(landblock) + : null, + buildMeshOrNull) { } @@ -311,13 +331,14 @@ public sealed class LandblockStreamer : IDisposable // factory returns far-tier with entities anyway. try { - var lb = _loadLandblock(load.LandblockId, load.Kind); - if (lb is null) + var build = _loadLandblock(load.LandblockId, load.Kind); + if (build is null) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, "LandblockLoader.Load returned null")); break; } + var lb = build.Landblock; if (load.Kind == LandblockStreamJobKind.PromoteToNear) { var promotedMesh = _buildMeshOrNull(load.LandblockId, lb); @@ -328,7 +349,7 @@ public sealed class LandblockStreamer : IDisposable break; } _outbox.Writer.TryWrite(new LandblockStreamResult.Promoted( - load.LandblockId, lb, promotedMesh)); + load.LandblockId, build, promotedMesh)); break; } var mesh = _buildMeshOrNull(load.LandblockId, lb); @@ -351,9 +372,10 @@ public sealed class LandblockStreamer : IDisposable lb.LandblockId, lb.Heightmap, System.Array.Empty()); + build = new LandblockBuild(lb); } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( - load.LandblockId, tier, lb, mesh)); + load.LandblockId, tier, build, mesh)); } catch (Exception ex) { diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 2e1f8682..99da350e 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -20,7 +20,7 @@ public sealed class StreamingController private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; - private readonly Action _applyTerrain; + private readonly Action _applyTerrain; private readonly Action? _removeTerrain; private readonly Action? _clearPendingLoads; @@ -140,7 +140,7 @@ public sealed class StreamingController Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, - Action applyTerrain, + Action applyTerrain, GpuWorldState state, int nearRadius, int farRadius, @@ -233,6 +233,20 @@ public sealed class StreamingController /// re-sent spawn or a same-frame double call costs nothing. Render-thread only, /// same as . /// + public void InitializeKnownLoginCenter(int cx, int cy, bool isSealedDungeon) + { + // StreamingReadinessGate keeps the worker stopped until the player's + // real spawn supplies this center. There is therefore no guessed-center + // window to force-reload here. A sealed dungeon is the one special case: + // pin radius zero before the first Tick so no ocean-grid neighbours are + // ever enqueued. + if (isSealedDungeon) + PreCollapseToDungeon(cx, cy); + } + + /// + /// Pins streaming to a sealed dungeon before the first normal Tick. + /// public void PreCollapseToDungeon(int cx, int cy) { uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); @@ -438,7 +452,7 @@ public sealed class StreamingController switch (result) { case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock, loaded.MeshData); + _applyTerrain(loaded.Build, loaded.MeshData); _state.AddLandblock(loaded.Landblock); // #138: after the landblock is in _loaded (so AppendLiveEntity // hot-paths), restore any retained server objects ACE won't @@ -446,7 +460,7 @@ public sealed class StreamingController _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); break; case LandblockStreamResult.Promoted promoted: - _applyTerrain(promoted.Landblock, promoted.MeshData); + _applyTerrain(promoted.Build, promoted.MeshData); _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); _onLandblockLoaded?.Invoke(promoted.LandblockId); break; diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs new file mode 100644 index 00000000..bfa789ae --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs @@ -0,0 +1,39 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public class CellVisibilityCommitTests +{ + [Fact] + public void CommitLandblock_ReplacesOnlyThatLandblocksCompleteCellSet() + { + var visibility = new CellVisibility(); + visibility.CommitLandblock(0x8C04FFFFu, new[] + { + Cell(0x8C040100u), + Cell(0x8C040101u), + }); + visibility.CommitLandblock(0x0007FFFFu, new[] { Cell(0x00070100u) }); + + visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x8C040200u) }); + + Assert.False(visibility.TryGetCell(0x8C040100u, out _)); + Assert.False(visibility.TryGetCell(0x8C040101u, out _)); + Assert.True(visibility.TryGetCell(0x8C040200u, out _)); + Assert.True(visibility.TryGetCell(0x00070100u, out _)); + Assert.Single(visibility.GetCellsForLandblock(0x8C04u)); + } + + [Fact] + public void CommitLandblock_RejectsForeignCellBeforeChangingCurrentState() + { + var visibility = new CellVisibility(); + visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x8C040100u) }); + + Assert.Throws(() => + visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x00070100u) })); + Assert.True(visibility.TryGetCell(0x8C040100u, out _)); + } + + private static LoadedCell Cell(uint id) => new() { CellId = id }; +} diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs index 881a8d7d..30300c8b 100644 --- a/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs @@ -1,4 +1,4 @@ -// CellVisibilityPortalPolygonsTests.cs — verifies BuildLoadedCell preserves +// CellVisibilityPortalPolygonsTests.cs — verifies the cell-build transaction preserves // portal polygon vertices in LoadedCell.PortalPolygons. // // Phase A8 — Indoor-cell visibility culling. Issue #78. diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs index d5a0c004..3d5a8213 100644 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -18,7 +18,7 @@ namespace AcDream.App.Tests.Rendering; /// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees /// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those /// eye positions. This harness drives the REAL -/// over the REAL Holtburg building cells (dat-loaded, mirroring GameWindow.BuildLoadedCell) +/// over the REAL Holtburg building cells (dat-loaded, mirroring EnvCellLandblockBuildBuilder) /// along the captured corner eye path, and prints which cells the flood keeps/drops per /// step. The player's room (0172) dropping while the player is visible on screen is the /// background defect; the step where it happens pins the mechanism. @@ -50,7 +50,7 @@ public class CornerFloodReplayTests } /// - /// Dat → LoadedCell, mirroring GameWindow.BuildLoadedCell (GameWindow.cs:5636-5776) + /// Dat → LoadedCell, mirroring EnvCellLandblockBuildBuilder. /// field-for-field: portals (with OtherPortalId back-link), clip planes from the /// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in /// cell-local space, local AABB, world transform from EnvCell.Position. @@ -106,7 +106,7 @@ public class CornerFloodReplayTests var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); float d = -Vector3.Dot(normal, p0); // InsideSide from the dat PortalSide bit — mirrors the production fix - // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics + // (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics // CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin // connectors (#186). Kept identical here so this replay stays a faithful mirror. clipPlanes.Add(new PortalClipPlane diff --git a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs index 205a3034..14b0e6c3 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs @@ -48,7 +48,7 @@ public class Issue113MeetingHallFloodTests return Directory.Exists(def) ? def : null; } - // Mirrors CornerFloodReplayTests.LoadCell (GameWindow.BuildLoadedCell shape). + // Mirrors CornerFloodReplayTests.LoadCell (EnvCellLandblockBuildBuilder shape). private static LoadedCell LoadCell(DatCollection dats, uint cellId) { var envCell = dats.Get(cellId) @@ -95,7 +95,7 @@ public class Issue113MeetingHallFloodTests var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); float d = -Vector3.Dot(normal, p0); // InsideSide from the dat PortalSide bit — mirrors the production fix - // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics + // (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics // CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin // connectors (#186). Kept identical here so this replay stays a faithful mirror. clipPlanes.Add(new PortalClipPlane diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs new file mode 100644 index 00000000..b53a3db9 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class EnvCellLandblockBuildTests +{ + private const uint LandblockId = 0x8C04FFFFu; + + [Fact] + public void Constructor_SnapshotsInputCollections() + { + var cells = new List { new() { CellId = 0x8C040100u } }; + var shells = new List { Shell(0x8C040100u, 1) }; + + var build = new EnvCellLandblockBuild(LandblockId, cells, shells); + cells.Clear(); + shells.Clear(); + + Assert.Single(build.VisibilityCells); + Assert.Single(build.Shells); + } + + [Fact] + public void Constructor_RejectsCellFromAnotherLandblock() + { + var foreign = new LoadedCell { CellId = 0x00070100u }; + + Assert.Throws(() => + new EnvCellLandblockBuild(LandblockId, new[] { foreign }, Array.Empty())); + } + + [Fact] + public void CreateCommittedSnapshot_ContainsEveryShellInOneCompleteReplacement() + { + var shells = Enumerable.Range(0, 1123) + .Select(index => Shell(0x8C040100u + (uint)index, (ulong)(index % 17 + 1))) + .ToArray(); + var build = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + shells); + + var snapshot = EnvCellRenderer.CreateCommittedSnapshot(build); + + Assert.Equal(1123, snapshot.Instances.Count); + Assert.Equal(1123, snapshot.EnvCellBounds.Count); + Assert.Equal(1123, snapshot.BuildingPartGroups.Values.Sum(group => group.Count)); + Assert.True(snapshot.InstancesReady); + Assert.True(snapshot.MeshDataReady); + Assert.True(snapshot.GpuReady); + } + + [Fact] + public void IndependentBuilds_NeverSharePendingStorage() + { + var complete = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + Enumerable.Range(0, 1123) + .Select(index => Shell(0x8C040100u + (uint)index, 1))); + var second = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + Enumerable.Range(0, 11) + .Select(index => Shell(0x8C040500u + (uint)index, 2))); + + var completeSnapshot = EnvCellRenderer.CreateCommittedSnapshot(complete); + var secondSnapshot = EnvCellRenderer.CreateCommittedSnapshot(second); + + Assert.Equal(1123, completeSnapshot.Instances.Count); + Assert.Equal(11, secondSnapshot.Instances.Count); + Assert.Equal(1123, completeSnapshot.Instances.Count); + } + + [Fact] + public void Builder_CanPublishItsTransactionOnlyOnce() + { + var builder = new EnvCellLandblockBuildBuilder(LandblockId); + + _ = builder.Build(); + + Assert.Throws(() => builder.Build()); + } + + private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId) + { + var min = new Vector3(cellId & 0xFF, 0, 0); + var bounds = new WbBoundingBox(min, min + Vector3.One); + return new EnvCellShellPlacement( + cellId, + geometryId, + EnvironmentId: 1, + CellStructure: 0, + Surfaces: ImmutableArray.Create(1), + WorldPosition: min, + Rotation: Quaternion.Identity, + Transform: Matrix4x4.CreateTranslation(min), + LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One), + WorldBounds: bounds); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs index c29f299e..a55b3b5d 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs @@ -67,12 +67,4 @@ public class EnvCellSceneryInstanceTests Assert.Equal(2, lb.BuildingPartGroups[0x01000001UL].Count); } - [Fact] - public void Landblock_PendingInstancesNullByDefault() - { - var lb = new EnvCellLandblock(); - Assert.Null(lb.PendingInstances); - Assert.Null(lb.PendingEnvCellBounds); - Assert.Null(lb.PendingSeenOutsideCells); - } } diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index 3f8a4afb..da29611b 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using AcDream.App.Streaming; +using AcDream.App.Rendering.Wb; using AcDream.Core.World; using DatReaderWriter.DBObjs; using Xunit; @@ -109,6 +110,35 @@ public class LandblockStreamerTests Assert.Equal(1, meshBuildCalls); } + [Fact] + public async Task Load_CarriesTheExactCompletedCellTransactionToTheConsumer() + { + var landblock = new LoadedLandblock( + 0x8C04FFFFu, + new LandBlock(), + System.Array.Empty()); + var cellBuild = new EnvCellLandblockBuild( + landblock.LandblockId, + System.Array.Empty(), + System.Array.Empty()); + var build = new LandblockBuild(landblock, cellBuild); + var mesh = new AcDream.Core.Terrain.LandblockMeshData( + System.Array.Empty(), + System.Array.Empty()); + + using var streamer = new LandblockStreamer( + loadLandblock: (_, _) => build, + buildMeshOrNull: (_, _) => mesh); + streamer.Start(); + streamer.EnqueueLoad(landblock.LandblockId, LandblockStreamJobKind.LoadNear); + + var loaded = Assert.IsType( + await DrainFirstAsync(streamer)); + + Assert.Same(build, loaded.Build); + Assert.Same(cellBuild, loaded.Build.EnvCells); + } + [Fact] public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index 522a4d07..bfc76bcf 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -163,6 +163,33 @@ public class StreamingControllerDungeonGateTests Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); } + [Fact] + public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn() + { + var h = Make(); + + h.Ctrl.InitializeKnownLoginCenter(0, 7, isSealedDungeon: true); + h.Ctrl.InitializeKnownLoginCenter(0, 7, isSealedDungeon: true); + + Assert.Single(h.Loads); + Assert.Equal(Encode(0, 7), h.Loads[0].Id); + Assert.Equal(1, h.ClearCalls()); + } + + [Fact] + public void InitializeKnownLoginCenter_OutdoorWaitsForFirstCorrectlyCenteredTick() + { + var h = Make(); + + h.Ctrl.InitializeKnownLoginCenter(140, 4, isSealedDungeon: false); + + Assert.Empty(h.Loads); + Assert.Equal(0, h.ClearCalls()); + + h.Ctrl.Tick(140, 4, insideDungeon: false); + Assert.Contains(h.Loads, load => load.Id == Encode(140, 4)); + } + [Fact] public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index 3364d774..20f45cf2 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -73,7 +73,7 @@ public class StreamingControllerTests var applied = new List(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2); + (build, _) => applied.Add(build.Landblock), state, nearRadius: 2, farRadius: 2); // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index ad32ae08..0acab3bd 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -131,7 +131,7 @@ public class StreamingControllerTwoTierTests applyTerrain: (appliedLb, appliedMesh) => { applyPromotedCount++; - Assert.Same(promotedLb, appliedLb); + Assert.Same(promotedLb, appliedLb.Landblock); Assert.Same(promotedMesh, appliedMesh); }, state: state,