fix(streaming): commit EnvCell landblocks atomically (#214)
Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
85980fe13f
commit
b0c175afc0
26 changed files with 973 additions and 617 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
106
docs/research/2026-07-13-envcell-landblock-transaction.md
Normal file
106
docs/research/2026-07-13-envcell-landblock-transaction.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -263,6 +263,32 @@ public sealed class CellVisibility
|
|||
_cellLookup[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="landblockId">Full landblock id, e.g. 0xA9B4FFFF.</param>
|
||||
public void CommitLandblock(uint landblockId, IReadOnlyList<LoadedCell> 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<LoadedCell>(cells);
|
||||
_cellsByLandblock[prefix] = committed;
|
||||
foreach (var cell in committed)
|
||||
_cellLookup[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
|
||||
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when
|
||||
|
|
|
|||
|
|
@ -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<uint, AcDream.Core.Terrain.SurfaceInfo>? _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<AcDream.Core.Meshing.GfxObjSubMesh>>
|
||||
_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<LoadedCell> _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
|
|||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||||
/// early-out at the source.
|
||||
/// </summary>
|
||||
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<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||||
if (heightmapOnly is null) return null;
|
||||
return new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
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.WorldEntity>(),
|
||||
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<AcDream.Core.World.WorldEntity>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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 <paramref name="envCellBuild"/>. 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.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
|
||||
uint landblockId, int lbX, int lbY)
|
||||
uint landblockId,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
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.
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
|
||||
/// for render-thread registration. Called from the worker thread during
|
||||
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
|
||||
/// </summary>
|
||||
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<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<System.Numerics.Vector3[]>();
|
||||
|
||||
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<System.Numerics.Vector3>();
|
||||
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<System.Numerics.Vector3>();
|
||||
}
|
||||
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<uint>();
|
||||
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<uint, LoadedCell>();
|
||||
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<uint, LoadedCell>();
|
||||
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<ushort, System.Numerics.Vector3>(
|
||||
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<uint, LoadedCell>(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
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
299
src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
Normal file
299
src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="EnvCellLandblockBuild"/>.
|
||||
/// </summary>
|
||||
public sealed record EnvCellShellPlacement(
|
||||
uint CellId,
|
||||
ulong GeometryId,
|
||||
uint EnvironmentId,
|
||||
ushort CellStructure,
|
||||
ImmutableArray<ushort> Surfaces,
|
||||
Vector3 WorldPosition,
|
||||
Quaternion Rotation,
|
||||
Matrix4x4 Transform,
|
||||
WbBoundingBox LocalBounds,
|
||||
WbBoundingBox WorldBounds);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class EnvCellLandblockBuild
|
||||
{
|
||||
public EnvCellLandblockBuild(
|
||||
uint landblockId,
|
||||
IEnumerable<LoadedCell> visibilityCells,
|
||||
IEnumerable<EnvCellShellPlacement> 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<LoadedCell> VisibilityCells { get; }
|
||||
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Build"/> returns.
|
||||
/// </summary>
|
||||
public sealed class EnvCellLandblockBuildBuilder
|
||||
{
|
||||
private readonly uint _landblockId;
|
||||
private readonly List<LoadedCell> _visibilityCells = new();
|
||||
private readonly List<EnvCellShellPlacement> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim arithmetic).
|
||||
/// </summary>
|
||||
public static ulong ComputeGeometryId(
|
||||
uint environmentId,
|
||||
ushort cellStructure,
|
||||
IReadOnlyList<ushort> 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<Vector3> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
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<Vector3>();
|
||||
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<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polygonVertices);
|
||||
}
|
||||
|
||||
uint landblockPrefix = envCellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ushort>(shell.Surfaces));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ushort, ObjectLandblock> _landblocks —
|
||||
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
|
||||
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
|
||||
|
|
@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
|
||||
/// </summary>
|
||||
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Registers a single EnvCell and its static objects into the per-landblock
|
||||
/// pending list. Call <see cref="FinalizeLandblock"/> 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.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// Pure half of <see cref="CommitLandblock"/>. Kept separate so transaction
|
||||
/// replacement semantics are regression-tested without an OpenGL context.
|
||||
/// </summary>
|
||||
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<EnvCellSceneryInstance>(capacity: 32);
|
||||
lb.PendingInstances.Add(cellInstance);
|
||||
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
|
||||
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<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically promotes the pending instance list to the committed list,
|
||||
/// computes total EnvCell bounds, populates PartGroups, and marks the
|
||||
/// landblock ready for rendering.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
NeedsPrepare = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PopulatePartGroups
|
||||
// Verbatim port of WB EnvCellRenderManager.cs:572-580.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
|
||||
/// Must be called under lb.Lock.
|
||||
/// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
|
||||
/// adding one <see cref="InstanceData"/> per leaf GfxObj to the target group.
|
||||
/// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
|
||||
/// </summary>
|
||||
private void PopulateRecursive(
|
||||
Dictionary<ulong, List<InstanceData>> 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<InstanceData>();
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Computes a local-space AABB from a CellStruct's vertex array.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms a local AABB to a world AABB by transforming all 8 corners.
|
||||
/// </summary>
|
||||
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<Vector3> 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -90,18 +90,6 @@ public class EnvCellLandblock
|
|||
/// </summary>
|
||||
public HashSet<uint> SeenOutsideCells { get; set; } = new();
|
||||
|
||||
public List<EnvCellSceneryInstance>? PendingInstances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Grouped bounding boxes for each EnvCell in this landblock (pending upload).
|
||||
/// </summary>
|
||||
public Dictionary<uint, WbBoundingBox>? PendingEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload).
|
||||
/// </summary>
|
||||
public HashSet<uint>? PendingSeenOutsideCells { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
|||
/// </summary>
|
||||
public WbBoundingBox TotalEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bounding box covering all EnvCells in this landblock (pending upload).
|
||||
/// </summary>
|
||||
public WbBoundingBox PendingTotalEnvCellBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether instances (positions/bounding boxes) have been generated.
|
||||
/// </summary>
|
||||
|
|
|
|||
16
src/AcDream.App/Streaming/LandblockBuild.cs
Normal file
16
src/AcDream.App/Streaming/LandblockBuild.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record LandblockBuild(
|
||||
LoadedLandblock Landblock,
|
||||
EnvCellLandblockBuild? EnvCells = null)
|
||||
{
|
||||
public uint LandblockId => Landblock.LandblockId;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A previously-Far-resident landblock was promoted to Near. The result
|
||||
|
|
@ -56,10 +68,19 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
/// </summary>
|
||||
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<WorldEntity> Entities => Landblock.Entities;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// </summary>
|
||||
public const int DefaultDrainBatchSize = 4;
|
||||
|
||||
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock;
|
||||
private readonly Func<uint, LandblockStreamJobKind, LandblockBuild?> _loadLandblock;
|
||||
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
|
||||
private readonly Channel<LandblockStreamJob> _inbox;
|
||||
private readonly Channel<LandblockStreamResult> _outbox;
|
||||
|
|
@ -68,7 +68,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
|
||||
/// </summary>
|
||||
public LandblockStreamer(
|
||||
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
|
||||
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
|
||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
||||
{
|
||||
_loadLandblock = loadLandblock;
|
||||
|
|
@ -81,6 +81,22 @@ public sealed class LandblockStreamer : IDisposable
|
|||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility constructor for loaders that have no App-owned cell payload.
|
||||
/// Production uses the <see cref="LandblockBuild"/> overload so render state
|
||||
/// remains attached to the exact streaming completion that produced it.
|
||||
/// </summary>
|
||||
public LandblockStreamer(
|
||||
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
|
||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
||||
: this(
|
||||
(id, kind) => loadLandblock(id, kind) is { } landblock
|
||||
? new LandblockBuild(landblock)
|
||||
: null,
|
||||
buildMeshOrNull)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<uint, LoadedLandblock?> loadLandblock,
|
||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? 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<AcDream.Core.World.WorldEntity>());
|
||||
build = new LandblockBuild(lb);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId, tier, lb, mesh));
|
||||
load.LandblockId, tier, build, mesh));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public sealed class StreamingController
|
|||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
||||
private readonly Action<uint> _enqueueUnload;
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action<uint>? _removeTerrain;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ public sealed class StreamingController
|
|||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||||
Action<uint> enqueueUnload,
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
|
||||
Action<LandblockBuild, LandblockMeshData> 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 <see cref="Tick"/>.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins streaming to a sealed dungeon before the first normal Tick.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<ArgumentException>(() =>
|
||||
visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x00070100u) }));
|
||||
Assert.True(visibility.TryGetCell(0x8C040100u, out _));
|
||||
}
|
||||
|
||||
private static LoadedCell Cell(uint id) => new() { CellId = id };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <see cref="PortalVisibilityBuilder.Build"/>
|
||||
/// 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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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<DatEnvCell>(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
|
||||
|
|
|
|||
|
|
@ -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<LoadedCell> { new() { CellId = 0x8C040100u } };
|
||||
var shells = new List<EnvCellShellPlacement> { 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<ArgumentException>(() =>
|
||||
new EnvCellLandblockBuild(LandblockId, new[] { foreign }, Array.Empty<EnvCellShellPlacement>()));
|
||||
}
|
||||
|
||||
[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<LoadedCell>(),
|
||||
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<LoadedCell>(),
|
||||
Enumerable.Range(0, 1123)
|
||||
.Select(index => Shell(0x8C040100u + (uint)index, 1)));
|
||||
var second = new EnvCellLandblockBuild(
|
||||
LandblockId,
|
||||
Array.Empty<LoadedCell>(),
|
||||
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<InvalidOperationException>(() => 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<ushort>(1),
|
||||
WorldPosition: min,
|
||||
Rotation: Quaternion.Identity,
|
||||
Transform: Matrix4x4.CreateTranslation(min),
|
||||
LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One),
|
||||
WorldBounds: bounds);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorldEntity>());
|
||||
var cellBuild = new EnvCellLandblockBuild(
|
||||
landblock.LandblockId,
|
||||
System.Array.Empty<AcDream.App.Rendering.LoadedCell>(),
|
||||
System.Array.Empty<EnvCellShellPlacement>());
|
||||
var build = new LandblockBuild(landblock, cellBuild);
|
||||
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: (_, _) => build,
|
||||
buildMeshOrNull: (_, _) => mesh);
|
||||
streamer.Start();
|
||||
streamer.EnqueueLoad(landblock.LandblockId, LandblockStreamJobKind.LoadNear);
|
||||
|
||||
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
|
||||
await DrainFirstAsync(streamer));
|
||||
|
||||
Assert.Same(build, loaded.Build);
|
||||
Assert.Same(cellBuild, loaded.Build.EnvCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ public class StreamingControllerTests
|
|||
var applied = new List<LoadedLandblock>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue