diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d760231b..448b0518 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -330,9 +330,9 @@ research and is no longer active.
| ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| ~~AP-34~~ | **RETIRED 2026-09-04 (S4-c2 fix round 1) — residuals AP-238 / AP-239 / AP-240.** Historical: a single acdream-only distance-sorted alpha queue stood in for retail's two independent per-`DrawMesh`-invocation FIFO lists (CLIP/ALPHA, capacity 3000 each) drained at retail's four normal-world `FlushAlphaList` sites (`DrawBuilding`/`DrawBlock`/`PView::DrawCells`/`RenderNormalMode`). S4-c2 (`048d5b12f`) replaced it with `RetailAlphaQueue`'s exact two-list FIFO port of `D3DPolyRender::AddMeshToAlphaList`'s append-only/capacity-drop behavior, routed by `RetailAlphaMeshRouter`'s port of `DrawMesh`'s five-row branch table, under retail's exact no-op threshold rule — no acdream-only sort remains anywhere on the alpha path. | retired | — | — | `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `D3DPolyRender::DrawMesh` 0x0059D4A0 |
-| AP-238 | **S4-c2 (2026-09-04); NARROWED FURTHER at the S4-c2 fix round 1 (2026-09-04) — renumbered from the colliding id "AP-236" (that id was already filed AND retired on `main` by an unrelated #132 fix; ids are never reused, per A2).** One of TWO residuals AP-34's closure left open (the other is AP-240's CLIP-list-for-ordinary-content gap) — NOT "the ONE residual" as originally claimed. `RetailAlphaQueue` is retail's own two independent FIFO lists (CLIP/ALPHA), routed by `RetailAlphaMeshRouter`'s port of `DrawMesh`'s branch table and drained at retail's four normal-world `FlushAlphaList` sites. Fix round 1 (M6) then wired EnvCell's transparent shell into that SAME table: `RetailPViewPassExecutor.SubmitOrDrawTransparentCellShell` routes through `RetailAlphaMeshRouter.Route` with the shell's mask (always 0x02, alpha-family) — detail ON takes Row 1 (immediate, unchanged from before); detail OFF now takes Row 3 (Append) instead of drawing immediately, appending ONE token per CELL via the new `EnvCellAlphaDrawSource`, replayed through `EnvCellRenderer.RenderTransparentOrdered` at the queue's next flush. The residual is now PURELY the token GRANULARITY, not "never through the shared queue" (that claim is retired) — no "substantial separate architecture change" was needed; `RenderTransparentOrdered` already replayed a cell's transparent batch on demand. | `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`SubmitOrDrawTransparentCellShell`, `EnvCellAlphaDrawSource`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (`TransparentDetailEnabled`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` | EnvCell's per-cell transparent batch is homogeneously mask 0x02 (EnvCell shells never carry a ClipMap bit in this path), so one atomic per-cell FIFO token is consistent with how WbDrawDispatcher/ParticleRenderer already submit at instance granularity, not per-D3D-surface-subset; a particle or ordinary GfxObj instance appended between two cell tokens keeps its own position (the queue's adjacent-only batching invariant) — only interleaving BETWEEN two subsets of the SAME cell's own batch is unavailable, and nothing needs that today. | A single EnvCell's transparent shell (glass, water, grates) with multiple distinct subsets composites those subsets in strict draw-call order RELATIVE TO EACH OTHER (no visual change from before this round); the coarser per-cell token only matters if a future feature needs to interleave other content between two subsets of that SAME cell's own batch. | `RenderDeviceD3D::DrawEnvCell` 0x0059F170; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; `D3DPolyRender::FlushAlphaList` 0x0059D2E0 |
-| AP-239 | **Filed 2026-09-04 (S4-c2); renumbered from the colliding id "AP-237" at the S4-c2 fix round 1 (2026-09-04, A2) — ids are never reused; the dangling "AP-236b" citation at `RetailAlphaMeshRouter.cs` (which never matched either filed id) is corrected to this one in the same fix.** `RetailAlphaMeshRouter.MaskFromTranslucencyKind` reconstructs retail's `ConstructMesh` queue-routing mask (spec: alpha-family bits always win; else ClipMap; else Translucent) from acdream's already-collapsed `TranslucencyKind`, which was built for a DIFFERENT priority chain (`TranslucencyKindExtensions.FromSurfaceType`'s blend-STATE selection: a "Translucent override" step checks Translucent+ClipMap-without-alpha-family FIRST and calls it `AlphaBlend`). A raw `Surface.Type` with Translucent(0x10) and Base1ClipMap(0x04) bits but no Alpha/InvAlpha/Additive bit therefore reconstructs to mask 0x02 (routes to ALPHA) here, while retail's real `ConstructMesh` mask is 0x08 (routes to CLIP) — `GroupKey` does not retain the raw ClipMap bit needed to distinguish the two `AlphaBlend` origins, and threading it through `GfxObjSubMesh`/`GroupKey`/every mesh-classification call site was judged out of this bounded chunk's scope. **S4-c2 fix round 1 (M7, 2026-09-04): the original cited example was DISPROVEN and replaced.** Cloud GfxObj `0x01004C35`'s surface `0x08000023` is `Type=0x10114` (ADDITIVE\|ALPHA\|TRANSLUCENT\|BASE1_CLIPMAP) — its alpha-family bits win in BOTH retail's real priority and this reconstruction, so it reproduces IDENTICALLY (mask 0x02 either way) and never demonstrated the gap. A 2026-09-04 scan of every Portal-dat `Surface` (6,152 scanned) found 27 REAL instances of Translucent+Base1ClipMap with no alpha-family bit (`Type=0x14`); cross-referencing the Portal-dat `GfxObj` table found surface `0x08000015` referenced by GfxObj `0x010001EC` — a real, confirmed instance, not the disproven cloud one. | `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` (`MaskFromTranslucencyKind`'s own doc comment); `src/AcDream.Core/Meshing/TranslucencyKind.cs` (`FromSurfaceType`'s Translucent-override step) | Compositing-ORDER only (which FIFO list a subset enters), never a blend-mode or visual difference — the affected surface still renders alpha-blended exactly as retail's `SetSurface` configures it; only its position in the shared drain order can differ at a narrow overlap with other CLIP-list content. 27 of 6,152 scanned surfaces (2026-09-04) carry this shape — narrow, but not as narrow as "only the cloud." | A translucent+clipmap surface (e.g. GfxObj `0x010001EC`) interleaves with ordinary ALPHA-list content instead of CLIP-list content at a rare compositing overlap. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority); `D3DPolyRender::SetSurface` 0x0059C4D0 (the Translucent-override blend selection acdream's `TranslucencyKind` already matches) |
-| AP-240 | **Filed 2026-09-04 (S4-c2 fix round 1, M2).** `WbDrawDispatcher.IsOpaque(t) => t == Opaque \|\| t == ClipMap` (`WbDrawDispatcher.cs`) keeps every clip-mapped subset out of `_translucentDraws`/`SubmitWalkAlphaInstance`/`SubmitToAlphaQueue` entirely; retail's `ConstructMesh` gives clip-mapped surfaces mask 0x08 → `DrawMesh` row 3 → the CLIP list, and the S4-c2 capture evidence (six `*.alphadepth.log` files) shows 4,183 of 10,556 `AM` lines on CLIP (e.g. `(DrawBuilding,0,158,452)`: 158 real CLIP entries acdream's ordinary-content path reports as 0). Clip-mapped GfxObj/scenery/building subsets instead draw IMMEDIATELY on the `OpaqueAlphaToCoverage` pipeline (the pre-S4/VisualMaster-era translucency decision — not a pipeline change this round) and never enter retail's CLIP FIFO at all; the only CLIP-list feeders left after S4-c2 are clip-mapped MESH PARTICLES (M1's fix: `ParticleRenderer` applies no `IsOpaque`-style pre-filter, so a mesh particle classified `TranslucencyKind.ClipMap` genuinely reaches mask 0x08 and can append to CLIP). | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IsOpaque`); `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` (`MaskClipMap` — the only reachable feeder is `ParticleRenderer`) | Depth-writing alpha-to-coverage cutouts composite correctly regardless of draw order for the overwhelming majority of clip-mapped content (doors, windows, foliage) — the divergence is compositing ORDER at a narrow overlap with genuine CLIP-list content (clip-mapped particles), not a missing/wrong cutout. Changing this is a VisualMaster-era pipeline decision, out of this bounded chunk's scope. | Clip-mapped GfxObj/scenery/building content composites in draw-call order relative to other opaque-pipeline content instead of interleaving through retail's deferred CLIP drain with clip-mapped particles/EnvCell content — edges differ only at a narrow overlap, never a missing cutout. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority, row 3 → CLIP); `D3DPolyRender::DrawMesh` 0x0059D4A0 (row 3); `D3DPolyRender::AddMeshToAlphaList` 0x0059C230 |
+| AP-238 | **S4-c2 (2026-09-04); NARROWED FURTHER at fix rounds 1 and 2 — renumbered from the colliding id "AP-236" (that id was already filed AND retired on `main`; ids are never reused).** `RetailAlphaQueue` is retail's own independent CLIP/ALPHA FIFO pair. Fix round 2 now retains each real EnvCell `ObjectRenderBatch`'s `TextureBatchData.RetailSurfaceMask` and routes every transparent subset independently through `RetailAlphaMeshRouter`: canonical `0x08` Base1ClipMap subsets enter CLIP, `0x02` alpha-family subsets enter ALPHA, table-Immediate subsets draw at the cell turn, and detail-active eligible subsets remain immediate with detail. A mixed cell can therefore contribute to both lists. The remaining residual is only token granularity: acdream coalesces to at most ONE token per `(cell,list)` and replays a list-filtered subset view, where retail appends one entry per surface subset. | `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`SubmitOrDrawTransparentCellShell`, `EnvCellAlphaDrawSource`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` / `.Rhi.cs` (`GetTransparentRoutes`, `RouteTransparentBatch`, filtered `RenderTransparentOrdered`); `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`ObjectRenderBatch.RetailSurfaceMask`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs` | Per-list replay preserves the exact subset class and the queue's adjacent-only batching preserves interleaving around other sources; the approximation exists only within one cell/list when several same-list subsets are represented by one token. | At a genuine depth-overlap, collapsing multiple same-list subsets to one `(cell,list)` token can visibly change compositing if other translucent content should fall between those subsets in retail's FIFO. | `RenderDeviceD3D::DrawEnvCell` 0x0059F170; `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; canonical installed-DAT case `F4180104` surface `08000BFF` mask `0x08` |
+| AP-239 | **Filed 2026-09-04 (S4-c2); renumbered from the colliding id "AP-237" at fix round 1.** `RetailAlphaMeshRouter.MaskFromTranslucencyKind` reconstructs retail's `ConstructMesh` routing mask from acdream's already-collapsed `TranslucencyKind`. A raw `Surface.Type` with Translucent(0x10) and Base1ClipMap(0x04), but no Alpha/InvAlpha/Additive bit, collapses to `AlphaBlend` and reconstructs as mask `0x02` (ALPHA), while retail retains mask `0x08` (CLIP). `GroupKey` does not retain the raw ClipMap bit required to distinguish the two origins. The original cloud example was disproven; a 2026-09-04 scan found 27 real `Type=0x14` surfaces among 6,152, including surface `0x08000015` referenced by GfxObj `0x010001EC`. | `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` (`MaskFromTranslucencyKind`); `src/AcDream.Core/Meshing/TranslucencyKind.cs` (`FromSurfaceType`) | The affected surface keeps the same blend state, but choosing the wrong FIFO changes compositing order; at a genuine overlap that order difference can be visibly different. | A translucent+clipmap surface (e.g. GfxObj `0x010001EC`) interleaves with ALPHA-list content instead of CLIP-list content and can composite differently at a depth overlap. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority); `D3DPolyRender::SetSurface` 0x0059C4D0 |
+| AP-240 | **Filed 2026-09-04 (S4-c2 fix round 1, M2); narrowed at fix round 2.** `WbDrawDispatcher.IsOpaque(t) => t == Opaque \|\| t == ClipMap` keeps clip-mapped GfxObj/scenery/building subsets out of `_translucentDraws`/`SubmitWalkAlphaInstance`/`SubmitToAlphaQueue`; retail gives those subsets mask `0x08` and appends them to CLIP. They instead draw immediately on acdream's `OpaqueAlphaToCoverage` pipeline. Fix round 2 proves CLIP has two other production feeder families: clip-mapped mesh particles and exact-mask EnvCell subsets (canonical `F4180104` / surface `08000BFF`). The residual is therefore only ordinary GfxObj/scenery/building content, not an empty or particle-only CLIP list. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IsOpaque`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (`RouteTransparentBatch`); `src/AcDream.App/Rendering/ParticleRenderer.cs`; `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` | The cutout itself remains present, but immediate opaque-path placement differs from retail's deferred FIFO order. Changing it is a VisualMaster-era pipeline decision beyond this bounded chunk. | At an overlap with deferred CLIP content from particles or EnvCells, clip-mapped GfxObj/scenery/building edges can visibly composite in the wrong relative order. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority, row 3 → CLIP); `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230 |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
index 5e65f138..13b65b6f 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
@@ -27,8 +27,8 @@ namespace AcDream.App.Rendering;
/// natively and at no cost.
///
/// What differs from the GL arm, and why. The imperative
-/// glBlendFunc switch becomes five PIPELINES (two billboard blends, three
-/// mesh blends) because core Vulkan 1.3 does not make blend dynamic; the
+/// glBlendFunc switch becomes six PIPELINES (two billboard blends,
+/// opaque plus three mesh blends) because core Vulkan 1.3 does not make blend dynamic; the
/// per-flight VAO/VBO pool disappears because every ring allocation inside a
/// frame is already distinct memory that lives until the frame retires; the
/// binding-9 texture table is not bound at all, because the device owns the
@@ -49,6 +49,7 @@ public sealed unsafe partial class ParticleRenderer
private IGpuPipeline? _billboardAlphaPipeline;
private IGpuPipeline? _billboardAdditivePipeline;
+ private IGpuPipeline? _meshOpaquePipeline;
private IGpuPipeline? _meshAlphaPipeline;
private IGpuPipeline? _meshAdditivePipeline;
private IGpuPipeline? _meshInversePipeline;
@@ -154,6 +155,8 @@ public sealed unsafe partial class ParticleRenderer
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
+ _reserveDeferredParticleDraw = ReserveDispatchDeferredParticle;
+ _drawImmediateParticle = DrawImmediateParticleSubmissionRhi;
long scratchBudget = alphaScratchBudgetBytes
?? AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
AcDream.App.Rendering.Residency.ResidencyBudgetOptions.Default.AlphaScratchBytes)
@@ -219,12 +222,14 @@ public sealed unsafe partial class ParticleRenderer
if (_meshAdapter?.MeshManager?.GlobalBuffer is null)
return;
+ _meshOpaquePipeline = CreateMeshParticlePipeline(
+ device, "particle-mesh-opaque", GpuBlendMode.None, depthWrite: true, sampleCount);
_meshAlphaPipeline = CreateMeshParticlePipeline(
- device, "particle-mesh-alpha", GpuBlendMode.StraightAlpha, sampleCount);
+ device, "particle-mesh-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, sampleCount);
_meshAdditivePipeline = CreateMeshParticlePipeline(
- device, "particle-mesh-additive", GpuBlendMode.Additive, sampleCount);
+ device, "particle-mesh-additive", GpuBlendMode.Additive, depthWrite: false, sampleCount);
_meshInversePipeline = CreateMeshParticlePipeline(
- device, "particle-mesh-inverse", GpuBlendMode.InverseAlpha, sampleCount);
+ device, "particle-mesh-inverse", GpuBlendMode.InverseAlpha, depthWrite: false, sampleCount);
}
///
@@ -273,6 +278,7 @@ public sealed unsafe partial class ParticleRenderer
IGpuDevice device,
string name,
GpuBlendMode blend,
+ bool depthWrite,
int sampleCount) =>
device.CreatePipeline(new GpuPipelineDescription
{
@@ -281,7 +287,7 @@ public sealed unsafe partial class ParticleRenderer
VertexLayout = MeshVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
- Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
+ Depth = new GpuDepthState(Test: true, Write: depthWrite, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = false,
@@ -366,7 +372,8 @@ public sealed unsafe partial class ParticleRenderer
instances.Buffer,
instances.OffsetBytes,
(uint)_meshRunScratch.Count,
- firstInstance: 0);
+ firstInstance: 0,
+ opaqueDepthState: false);
}
}
@@ -383,7 +390,8 @@ public sealed unsafe partial class ParticleRenderer
private void DrawImmediateParticleSubmissionRhi(
Matrix4x4 viewProjection,
ParticleSubmissionKind kind,
- int drawIndex)
+ int drawIndex,
+ bool opaqueDepthState)
{
IGpuPassEncoder encoder = _scope!.RequireEncoder();
IGpuFrame frame = RequireRhiFrame();
@@ -417,7 +425,8 @@ public sealed unsafe partial class ParticleRenderer
instances.Buffer,
instances.OffsetBytes,
instanceCount: 1,
- firstInstance: 0);
+ firstInstance: 0,
+ opaqueDepthState: opaqueDepthState);
}
private void DrawInstancesRhi(
@@ -502,12 +511,13 @@ public sealed unsafe partial class ParticleRenderer
IGpuBuffer instanceBuffer,
uint instanceOffsetBytes,
uint instanceCount,
- uint firstInstance)
+ uint firstInstance,
+ bool opaqueDepthState = false)
{
if (instanceCount == 0)
return;
- encoder.BindPipeline(PipelineForMeshBlend(ResolveMeshBlend(batch)));
+ encoder.BindPipeline(PipelineForMeshBlend(ResolveMeshBlend(batch), opaqueDepthState));
encoder.SetPushConstants(new GpuPushConstants
{
ViewProjection = viewProjection,
@@ -550,12 +560,41 @@ public sealed unsafe partial class ParticleRenderer
firstInstance);
}
- private IGpuPipeline PipelineForMeshBlend(TranslucencyKind blend) => blend switch
+ internal readonly record struct MeshParticlePipelineState(
+ GpuBlendMode Blend,
+ GpuDepthState Depth);
+
+ internal static MeshParticlePipelineState ResolveMeshParticlePipelineState(
+ TranslucencyKind blend,
+ bool opaqueDepthState) =>
+ opaqueDepthState
+ ? new MeshParticlePipelineState(
+ GpuBlendMode.None,
+ new GpuDepthState(Test: true, Write: true, WorldDepthContract.WorldCompare))
+ : new MeshParticlePipelineState(
+ blend switch
+ {
+ TranslucencyKind.Additive => GpuBlendMode.Additive,
+ TranslucencyKind.InvAlpha => GpuBlendMode.InverseAlpha,
+ _ => GpuBlendMode.StraightAlpha,
+ },
+ new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare));
+
+ private IGpuPipeline PipelineForMeshBlend(
+ TranslucencyKind blend,
+ bool opaqueDepthState)
{
- TranslucencyKind.Additive => _meshAdditivePipeline!,
- TranslucencyKind.InvAlpha => _meshInversePipeline!,
- _ => _meshAlphaPipeline!,
- };
+ MeshParticlePipelineState state = ResolveMeshParticlePipelineState(
+ blend,
+ opaqueDepthState);
+ return state.Blend switch
+ {
+ GpuBlendMode.None => _meshOpaquePipeline!,
+ GpuBlendMode.Additive => _meshAdditivePipeline!,
+ GpuBlendMode.InverseAlpha => _meshInversePipeline!,
+ _ => _meshAlphaPipeline!,
+ };
+ }
///
/// The RHI form of . FrontFace is
@@ -708,7 +747,8 @@ public sealed unsafe partial class ParticleRenderer
meshInstances,
_preparedMeshInstances.OffsetBytes,
(uint)(i - meshRunStart),
- meshBaseInstance);
+ meshBaseInstance,
+ opaqueDepthState: false);
}
}
}
@@ -756,6 +796,8 @@ public sealed unsafe partial class ParticleRenderer
_billboardAlphaPipeline = null;
Attempt(() => _billboardAdditivePipeline?.Dispose());
_billboardAdditivePipeline = null;
+ Attempt(() => _meshOpaquePipeline?.Dispose());
+ _meshOpaquePipeline = null;
Attempt(() => _meshAlphaPipeline?.Dispose());
_meshAlphaPipeline = null;
Attempt(() => _meshAdditivePipeline?.Dispose());
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs
index f32264b2..cfd289bd 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.cs
@@ -121,6 +121,9 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
private readonly ParticleSystem _particles;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
+ private readonly ReserveDeferredParticleDraw _reserveDeferredParticleDraw;
+ private readonly DrawImmediateParticle _drawImmediateParticle;
+ private DeferredParticleDraw _dispatchDeferredParticle;
private readonly Dictionary _particleGfxInfoByGfxObj = new();
private readonly Dictionary _particleGfxInfoByEmitter = new();
private readonly Dictionary _geometryKindByGfxObj = new();
@@ -185,6 +188,14 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
=> owner.ResetDeferredAlpha();
}
+ internal delegate int ReserveDeferredParticleDraw();
+
+ internal delegate void DrawImmediateParticle(
+ Matrix4x4 viewProjection,
+ ParticleSubmissionKind kind,
+ int drawIndex,
+ bool opaqueDepthState);
+
///
/// Starts one render frame. Wb point-of-use recovery is limited to one
/// request per missing GfxObj even though portal slicing may invoke Draw
@@ -332,8 +343,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
/// mask 0x00 is a real, reachable input here.
///
/// M1(b): retail's has_alpha (CMaterial::CheckAlphaValues
- /// @0x005396a0 — Ghidra-verified 2026-09-04: has_alpha=0 only when
- /// Ambient.a==Diffuse.a==Specular.a==Emissive.a==1.0f, else has_alpha=1)
+ /// @0x005396a0 — paired-binary verified 2026-09-04: any channel below
+ /// 1.0 sets has_alpha; values at or above 1.0 leave it clear)
/// is driven, for a particle, by CMaterial::SetTranslucencySimple
/// @0x005396f0 — the only production writer reached from a particle's
/// live translucency (CPhysicsPart::SetTranslucency <-
@@ -344,8 +355,10 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
/// CURRENT alpha isn't 1.0 (fully opaque)". acdream already carries that
/// exact interpolated value — Lerp(p.StartAlpha, p.EndAlpha, tLife),
/// baked into the top byte of by
- /// Color32 (ParticleSystem.cs:1039-1041,1453) — so no new state is
- /// needed. Billboards never reach row 4 (their mask 0x02 always
+ /// Color32 (ParticleSystem.cs:1039-1041,1453). Production clamps
+ /// particle alpha to [0,1], so top-byte != 0xff is behaviorally identical
+ /// to retail's below-1 predicate within this explicitly bounded domain;
+ /// no new state is needed. Billboards never reach row 4 (their mask 0x02 always
/// satisfies row 3), so only matters for
/// mesh particles.
///
@@ -353,8 +366,9 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
/// screen particles use the independent DrawOrdered path entirely, never
/// this method) and never installs a detail surface; MultiPassAlpha
/// stays false (Must Not: no environment override) — Row 2 is therefore
- /// provably unreachable here. Rows 1/5 (Immediate) ARE reachable for an
- /// Opaque-classified mesh-particle batch with no material alpha.
+ /// provably unreachable here. Row 5 (Immediate) is reachable for an
+ /// Opaque-classified mesh-particle batch with no material alpha; Row 1 is
+ /// not, because this site never installs a detail surface.
///
internal static RetailAlphaMeshDecision RouteParticleSubmission(
ParticleSubmissionKind kind, TranslucencyKind meshTranslucency, uint meshColorArgb)
@@ -394,50 +408,78 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
- RetailAlphaMeshDecision decision = submission.Kind == ParticleSubmissionKind.Billboard
- ? RouteParticleSubmission(submission.Kind, default, default)
- : RouteParticleSubmission(
- submission.Kind,
- _meshDrawListScratch[submission.DrawIndex].Batch.Translucency,
- _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb);
+ _dispatchDeferredParticle = deferred;
+ TranslucencyKind translucency = submission.Kind == ParticleSubmissionKind.Mesh
+ ? _meshDrawListScratch[submission.DrawIndex].Batch.Translucency
+ : default;
+ uint colorArgb = submission.Kind == ParticleSubmissionKind.Mesh
+ ? _meshDrawListScratch[submission.DrawIndex].Instance.ColorArgb
+ : default;
+ DeferToRetailAlphaQueue(
+ submission.Kind,
+ translucency,
+ colorArgb,
+ queue,
+ _alphaSource,
+ _reserveDeferredParticleDraw,
+ _drawImmediateParticle,
+ viewProjection,
+ submission.DrawIndex);
+ }
+ }
- switch (decision.Action)
+ ///
+ /// The production one-submission dispatch used by the scene-particle
+ /// loop. It owns both the queue append and the row-5 immediate callback,
+ /// so tests can discriminate the former throw/deleted-call mutations
+ /// without replacing the router with a pure surrogate.
+ ///
+ internal static RetailAlphaMeshDecision DeferToRetailAlphaQueue(
+ ParticleSubmissionKind kind,
+ TranslucencyKind meshTranslucency,
+ uint meshColorArgb,
+ RetailAlphaQueue queue,
+ IRetailAlphaDrawSource source,
+ ReserveDeferredParticleDraw reserveDeferred,
+ DrawImmediateParticle drawImmediate,
+ Matrix4x4 viewProjection,
+ int drawIndex)
+ {
+ RetailAlphaMeshDecision decision = RouteParticleSubmission(
+ kind,
+ meshTranslucency,
+ meshColorArgb);
+ switch (decision.Action)
+ {
+ case RetailAlphaMeshAction.Append:
{
- case RetailAlphaMeshAction.Append:
- {
- int token = _deferredAlpha.Count;
- _deferredAlpha.Add(deferred);
- // Capacity overflow (spec §5): dropped, no recovery.
- queue.TryAppend(decision.List, _alphaSource, token, decision.OverrideClipmap);
- break;
- }
-
- case RetailAlphaMeshAction.Immediate:
- // M1(c): rows 1/5 — draw NOW, through the same
- // per-instance draw code DrawOrderedRhi uses for one
- // submission (no new renderer). A8: no sort — retail's
- // immediate RenderMeshSubset draws in mesh/submission
- // order, so this draws exactly this submission, right
- // where DrawMesh was called.
- DrawImmediateParticleSubmissionRhi(viewProjection, submission.Kind, submission.DrawIndex);
- break;
-
- case RetailAlphaMeshAction.AppendClipAndImmediate:
- {
- // Row 2: provably unreachable at this call site
- // (MultiPassAlpha stays false), but a router row is a
- // data-driven outcome, never an invariant to crash on
- // (feedback_retail_dispatch_is_data_driven) — handled in
- // full, exactly as spec §4 row 2 describes: append to
- // CLIP AND ALSO draw immediately.
- int token = _deferredAlpha.Count;
- _deferredAlpha.Add(deferred);
- queue.TryAppend(decision.List, _alphaSource, token, decision.OverrideClipmap);
- DrawImmediateParticleSubmissionRhi(viewProjection, submission.Kind, submission.DrawIndex);
- break;
- }
+ int token = reserveDeferred();
+ queue.TryAppend(decision.List, source, token, decision.OverrideClipmap);
+ break;
+ }
+ case RetailAlphaMeshAction.Immediate:
+ // At this hardcoded no-detail/no-multipass site Immediate is
+ // DrawMesh row 5. Retail SetSurface uses ONE/ZERO, disables
+ // blending, and depth-writes; select that pipeline rather
+ // than the ordinary alpha mesh pipeline.
+ drawImmediate(viewProjection, kind, drawIndex, opaqueDepthState: true);
+ break;
+ case RetailAlphaMeshAction.AppendClipAndImmediate:
+ {
+ int token = reserveDeferred();
+ queue.TryAppend(decision.List, source, token, decision.OverrideClipmap);
+ drawImmediate(viewProjection, kind, drawIndex, opaqueDepthState: false);
+ break;
}
}
+ return decision;
+ }
+
+ private int ReserveDispatchDeferredParticle()
+ {
+ int token = _deferredAlpha.Count;
+ _deferredAlpha.Add(_dispatchDeferredParticle);
+ return token;
}
private void DrawOrdered(ICamera camera)
diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
index 7458c825..ac25bc7e 100644
--- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs
+++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
@@ -94,25 +94,21 @@ internal interface IRetailAlphaDrawSource
/// retail's per-entry clip byte (SetSurface's alpha-blend-vs-
/// clip-test arm selector).
///
-/// S4-c2 fix round 1 (M4): retail's per-entry new
-/// ("first-for-this-list") byte is NOT modeled here. Retail's quantifier is
-/// per-DrawMesh-INVOCATION: DrawMesh @0x0059d4a0 sets both
-/// list flags true at ENTRY (0059d4cc arg3 = 1; 0059d4d0 var_c = 1)
-/// and clears each independently after its OWN first append to that list
-/// WITHIN THAT CALL (0059d5ef if (var_4_1 == 0) var_c = 0; else arg3 =
-/// 0;) — one retail subset can append to only one list once, so this
-/// flag is trivially true for every subset retail ever appends; the
-/// captures confirm it (10,556 AM lines, new=1 on 9,685 of
-/// them — impossible under "first since the last drain", which acdream's
-/// deleted isFirstForList = target.Count == 0 computed instead). One
-/// acdream append already IS one full DrawMesh invocation (one
-/// instance, one call), so the truthful port of an always-true flag with no
-/// acdream reader (Vulkan's per-batch material/matrix binding makes
-/// retail's per-entry material-capture optimization a no-op here — see
-/// 's class doc comment) is to not carry it at
-/// all, rather than keep a field whose value can never vary and whose only
-/// possible test is therefore vacuous
-/// (feedback_every_new_pin_must_be_shown_to_fail).
+/// S4-c2 fix round 2: retail's per-entry new
+/// ("first-for-this-list") byte is NOT modeled here, but it is not
+/// always true. DrawMesh @0x0059d4a0 sets the CLIP and ALPHA flags
+/// true at ENTRY and clears each independently after the first successful
+/// append to that list within that one DrawMesh call. A call can walk
+/// several surface subsets and append more than once, including to both
+/// lists; the capture proves the varying value (10,556 AM lines:
+/// new=1 on 9,685, new=0 on 871). Acdream's retained submission
+/// units do not have a one-to-one relationship with retail DrawMesh
+/// invocations (ordinary instances and the accepted EnvCell `(cell,list)`
+/// token can aggregate several subsets), so synthesizing the bit would be
+/// false precision. No Vulkan draw reads the flag: every prepared batch
+/// binds its own material and matrix state. Omitting this unread capture-
+/// optimization bit is therefore the documented modern-backend adaptation,
+/// not a claim that retail's value cannot vary.
///
internal readonly record struct RetailAlphaEntry(
IRetailAlphaDrawSource Source,
@@ -174,8 +170,11 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
private int[] _sourceDrawOffsets = new int[4];
private readonly RetainedScratchCapacityPolicy _scratchPolicy;
private readonly long _scratchBudgetBytes;
+ private readonly Action? _drainObserver;
- internal RetailAlphaQueue(long? scratchBudgetBytes = null)
+ internal RetailAlphaQueue(
+ long? scratchBudgetBytes = null,
+ Action? drainObserver = null)
{
long budget = scratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
@@ -183,6 +182,7 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(budget);
_scratchBudgetBytes = budget;
_scratchPolicy = new RetainedScratchCapacityPolicy(budget);
+ _drainObserver = drainObserver;
}
public bool IsCollecting { get; private set; }
@@ -221,8 +221,8 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
/// caller ('s decision) supplies
/// . Retail's per-entry "first for
/// this list" flag is not modeled — see 's
- /// own doc comment (M4) for why it is trivially true for every subset
- /// retail ever appends and therefore carries no information here.
+ /// own doc comment for why the varying retail bit has no faithful
+ /// one-to-one producer or consumer in this retained Vulkan path.
///
internal bool TryAppend(
RetailAlphaList list,
@@ -237,11 +237,17 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
throw new ArgumentOutOfRangeException(nameof(token));
List target = list == RetailAlphaList.Clip ? _clip : _alpha;
+ // All production callers reserve source-owned payload before this
+ // call. Retail drops an entry at capacity, but the source still has
+ // frame-local payload that must be released at the next drain/abort.
+ // Register before the capacity return so a source whose FIRST append
+ // is rejected participates in that cleanup without preparing or
+ // drawing the rejected token.
+ RegisterSource(source);
if (target.Count >= ListCapacity)
return false;
target.Add(new RetailAlphaEntry(source, token, overrideClipmap));
- RegisterSource(source);
return true;
}
@@ -264,14 +270,17 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
///
public void Flush(RetailAlphaFlushSite site, float threshold)
{
- _ = site; // site distinguishes call sites for tracing/tests only —
- // the drain algorithm itself does not depend on which one.
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
if (_clip.Count < threshold * ListCapacity && _alpha.Count < threshold * ListCapacity)
return;
+ // Optional construction-time observer for the concrete outdoor-frame
+ // ownership gate. It sees only real drains (after retail's threshold
+ // early return), never alters the drain algorithm, and production
+ // composition leaves it null.
+ _drainObserver?.Invoke(site);
DrainAndReset();
}
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index 0c8241ef..ff9f18b7 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -10,6 +10,14 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
+internal interface IEnvCellImmediateDrawSink
+{
+ void DrawImmediate(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive);
+}
+
// S3 chunk 4 fix round 2 (L2): RetailPViewFramebufferSize / IRetailPViewFramebufferSource
// / SilkRetailPViewFramebufferSource are deleted — their only consumer was
// the RHI surface's NDC-to-pixel scissor conversion, and that scissor
@@ -41,7 +49,7 @@ internal sealed class RetailPViewCellSource : IRetailPViewCellSource
/// is written once; the four places it touches graphics state directly are owned
/// by .
///
-internal sealed partial class RetailPViewPassExecutor
+internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSink
{
private readonly IWorldPassSurface _surface;
private readonly IRenderFrameGlState _frameGlState;
@@ -57,7 +65,15 @@ internal sealed partial class RetailPViewPassExecutor
private readonly WorldRenderDiagnostics _diagnostics;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly HashSet _noSceneParticleEntityIds = [];
-public RetailPViewPassExecutor(
+ private readonly EnvCellAlphaDrawSource _envCellClipAlphaSource;
+ private readonly EnvCellAlphaDrawSource _envCellBlendAlphaSource;
+
+ internal delegate void RenderImmediateEnvCellRoute(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive);
+
+ public RetailPViewPassExecutor(
IWorldPassSurface surface,
IRenderFrameGlState frameGlState,
ClipFrame clipFrame,
@@ -87,6 +103,12 @@ public RetailPViewPassExecutor(
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_terrainDiagnostics = terrainDiagnostics
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
+ _envCellClipAlphaSource = new EnvCellAlphaDrawSource(
+ _envCells.RenderTransparentOrdered,
+ EnvCellTransparentRoute.Clip);
+ _envCellBlendAlphaSource = new EnvCellAlphaDrawSource(
+ _envCells.RenderTransparentOrdered,
+ EnvCellTransparentRoute.Alpha);
}
public void BeginFrame()
@@ -168,50 +190,126 @@ public RetailPViewPassExecutor(
/// S4-c2 fix round 1 (M6, contract C4): retail DrawEnvCell
/// @0x0059f1c2 installs the environment detail surface (may be null)
/// before DrawMesh @0x0059f212 and clears it @0x0059f21a — detail
- /// ON makes the subset take Row 1 (immediate, WITH detail —
+ /// ON makes eligible subsets take Row 1 (immediate, WITH detail —
/// 's own doc
- /// comment); detail OFF takes Row 3 (append; mask 0x02 alpha-family for
- /// an EnvCell transparent shell surface). Routed through the SAME table
- /// every other alpha submitter uses so the decision is the table's, not
- /// a hand-coded branch.
- /// already replays a cell's transparent batch on demand, so the queue
- /// side () needs no new deferred-
- /// replay abstraction — one token per CELL (coarser than retail's own
- /// per-subset entries; AP-236's residual documents that granularity gap).
+ /// comment); detail OFF still routes each real subset by the exact
+ /// TextureBatchData.RetailSurfaceMask retained on
+ /// ObjectRenderBatch. A pure Base1ClipMap mask 0x08 reaches CLIP;
+ /// alpha-family 0x02 reaches ALPHA; a table-Immediate mask stays at the
+ /// cell turn. The queue coalesces only to one token per (cell,list), the
+ /// accepted AP-238 granularity residual.
///
internal void SubmitOrDrawTransparentCellShell(uint cellId)
{
- RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
- currentlyDrawingSky: false,
- delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
- detailSurfaceActive: _envCells.TransparentDetailEnabled,
- multiPassAlpha: false,
- subsetMask: RetailAlphaMeshRouter.MaskAlphaFamily,
- materialHasAlpha: false);
-
- if (decision.Action == RetailAlphaMeshAction.Immediate)
- {
- _singleCellListScratch.Clear();
- _singleCellListScratch.Add(cellId);
- _envCells.RenderTransparentOrdered(_singleCellListScratch);
- return;
- }
-
- // Row 2 (AppendClipAndImmediate) needs MultiPassAlpha, fixed false
- // above — provably unreachable, same reasoning as
- // WbDrawDispatcher.SubmitToAlphaQueue's ordinary-content case.
- _envCellAlphaSource ??= new EnvCellAlphaDrawSource(_envCells.RenderTransparentOrdered);
- int token = _envCellAlphaSource.AddPendingCellId(cellId);
- _alpha.TryAppend(decision.List, _envCellAlphaSource, token, decision.OverrideClipmap);
+ bool detailSurfaceActive = _envCells.TransparentDetailEnabled;
+ EnvCellTransparentRoute routes = _envCells.GetTransparentRoutes(
+ cellId,
+ detailSurfaceActive);
+ DispatchTransparentCellShell(
+ cellId,
+ routes,
+ detailSurfaceActive,
+ _alpha,
+ _envCellClipAlphaSource,
+ _envCellBlendAlphaSource,
+ this);
}
- private EnvCellAlphaDrawSource? _envCellAlphaSource;
-
private readonly List _singleCellListScratch = new(1);
+ private void DrawImmediateEnvCellRoute(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive)
+ {
+ _singleCellListScratch.Clear();
+ _singleCellListScratch.Add(cellId);
+ _envCells.RenderTransparentOrdered(
+ _singleCellListScratch,
+ route,
+ detailSurfaceActive);
+ }
+
+ void IEnvCellImmediateDrawSink.DrawImmediate(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive) =>
+ DrawImmediateEnvCellRoute(cellId, route, detailSurfaceActive);
+
+ ///
+ /// Production dispatch for one cell after
+ /// scanned its real transparent batches. Separate source identities keep
+ /// CLIP and ALPHA replay filters attached to their tokens. Immediate
+ /// subsets draw at the cell turn; each deferred list receives at most one
+ /// token for this cell.
+ ///
+ internal static void DispatchTransparentCellShell(
+ uint cellId,
+ EnvCellTransparentRoute routes,
+ bool detailSurfaceActive,
+ RetailAlphaQueue queue,
+ EnvCellAlphaDrawSource clipSource,
+ EnvCellAlphaDrawSource alphaSource,
+ IEnvCellImmediateDrawSink renderImmediate)
+ {
+ if ((routes & EnvCellTransparentRoute.Immediate) != 0)
+ {
+ renderImmediate.DrawImmediate(
+ cellId,
+ EnvCellTransparentRoute.Immediate,
+ detailSurfaceActive);
+ }
+
+ if ((routes & EnvCellTransparentRoute.Clip) != 0)
+ {
+ int token = clipSource.AddPendingCellId(cellId);
+ queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true);
+ }
+
+ if ((routes & EnvCellTransparentRoute.Alpha) != 0)
+ {
+ int token = alphaSource.AddPendingCellId(cellId);
+ queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false);
+ }
+ }
+
+
+ /// Delegate adapter retained for focused route/allocation tests;
+ /// production calls the interface overload above with this, so no
+ /// per-cell delegate conversion or retained callback enters the owner.
+ internal static void DispatchTransparentCellShell(
+ uint cellId,
+ EnvCellTransparentRoute routes,
+ bool detailSurfaceActive,
+ RetailAlphaQueue queue,
+ EnvCellAlphaDrawSource clipSource,
+ EnvCellAlphaDrawSource alphaSource,
+ RenderImmediateEnvCellRoute renderImmediate)
+ {
+ if ((routes & EnvCellTransparentRoute.Immediate) != 0)
+ {
+ renderImmediate(
+ cellId,
+ EnvCellTransparentRoute.Immediate,
+ detailSurfaceActive);
+ }
+
+ if ((routes & EnvCellTransparentRoute.Clip) != 0)
+ {
+ int token = clipSource.AddPendingCellId(cellId);
+ queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true);
+ }
+
+ if ((routes & EnvCellTransparentRoute.Alpha) != 0)
+ {
+ int token = alphaSource.AddPendingCellId(cellId);
+ queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false);
+ }
+ }
+
///
/// The queue-facing half of :
- /// retains the cell ids submitted this frame in append order and replays
+ /// retains the cell ids submitted to one fixed list in append order and replays
/// a drained slice through
/// (production wiring) — one token per cell, never per subset (M6's
/// documented granularity residual). 's
@@ -224,7 +322,14 @@ public RetailPViewPassExecutor(
/// EnvCellAlphaDrawSourceTests can pin the queue mechanics
/// without standing up a GPU-backed renderer.
///
- internal sealed class EnvCellAlphaDrawSource(Action> renderTransparentOrdered)
+ internal delegate void RenderEnvCellsByRoute(
+ IReadOnlyList cellIds,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive);
+
+ internal sealed class EnvCellAlphaDrawSource(
+ RenderEnvCellsByRoute renderTransparentOrdered,
+ EnvCellTransparentRoute route)
: IRetailAlphaDrawSource
{
private readonly List _pendingCellIds = new();
@@ -254,14 +359,17 @@ public RetailPViewPassExecutor(
_drawScratch.Clear();
for (int i = 0; i < drawCount; i++)
_drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]);
- renderTransparentOrdered(_drawScratch);
+ renderTransparentOrdered(_drawScratch, route, detailSurfaceActive: false);
}
public void ResetAlphaSubmissions()
{
_pendingCellIds.Clear();
_preparedCellIds.Clear();
+ _drawScratch.Clear();
}
+
+ internal int PendingCount => _pendingCellIds.Count;
}
///
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index f41083eb..1b0f3562 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -28,6 +28,16 @@ using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
+[Flags]
+internal enum EnvCellTransparentRoute : byte
+{
+ None = 0,
+ Immediate = 1 << 0,
+ Clip = 1 << 1,
+ Alpha = 1 << 2,
+ All = Immediate | Clip | Alpha,
+}
+
public sealed partial class EnvCellRenderer :
IDisposable,
IEnvCellLandblockPublisher
@@ -803,7 +813,7 @@ public sealed partial class EnvCellRenderer :
public void Render(WbRenderPass renderPass)
{
// WB EnvCellRenderManager.cs:396:
- RenderCore(renderPass, null, null);
+ RenderCore(renderPass, null, null, EnvCellTransparentRoute.All, detailSurfaceActive: false);
}
///
@@ -817,7 +827,7 @@ public sealed partial class EnvCellRenderer :
/// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights).
///
public void Render(WbRenderPass renderPass, HashSet? filter)
- => RenderCore(renderPass, filter, null);
+ => RenderCore(renderPass, filter, null, EnvCellTransparentRoute.All, detailSurfaceActive: false);
///
/// Draws transparent cell shells in the supplied far-to-near PView order.
@@ -828,13 +838,40 @@ public sealed partial class EnvCellRenderer :
public void RenderTransparentOrdered(IReadOnlyList orderedCellIds)
{
ArgumentNullException.ThrowIfNull(orderedCellIds);
- RenderCore(WbRenderPass.Transparent, null, orderedCellIds);
+ RenderCore(
+ WbRenderPass.Transparent,
+ null,
+ orderedCellIds,
+ EnvCellTransparentRoute.All,
+ detailSurfaceActive: TransparentDetailEnabled);
+ }
+
+ ///
+ /// Replays only the transparent subsets whose exact retained retail mask
+ /// resolves to . S4-c2 uses this for its separate
+ /// CLIP/ALPHA queue tokens; the detail-on immediate turn uses the same
+ /// table with true.
+ ///
+ internal void RenderTransparentOrdered(
+ IReadOnlyList orderedCellIds,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive)
+ {
+ ArgumentNullException.ThrowIfNull(orderedCellIds);
+ RenderCore(
+ WbRenderPass.Transparent,
+ null,
+ orderedCellIds,
+ route,
+ detailSurfaceActive);
}
private void RenderCore(
WbRenderPass renderPass,
HashSet? filter,
- IReadOnlyList? orderedCellIds)
+ IReadOnlyList? orderedCellIds,
+ EnvCellTransparentRoute transparentRoute,
+ bool detailSurfaceActive)
{
// WB EnvCellRenderManager.cs:400: the RHI arm's three pipelines are built
// at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone
@@ -969,7 +1006,9 @@ public sealed partial class EnvCellRenderer :
drawCalls,
allInstances,
_drawCallRanges,
- renderPass);
+ renderPass,
+ transparentRoute,
+ detailSurfaceActive);
}
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
@@ -996,6 +1035,76 @@ public sealed partial class EnvCellRenderer :
public bool CellHasTransparent(uint cellId)
=> _transparentCellIds.Contains(cellId);
+ ///
+ /// Scans the real prepared transparent render batches for one cell and
+ /// returns the set of outcomes from retail's shared DrawMesh table. A
+ /// cell may contribute one token to each deferred list; masks that resolve
+ /// Immediate remain at the cell turn.
+ ///
+ internal EnvCellTransparentRoute GetTransparentRoutes(
+ uint cellId,
+ bool detailSurfaceActive)
+ {
+ lock (_renderLock)
+ {
+ if (!_activeSnapshot.BatchedByCell.TryGetValue(cellId, out var groups))
+ return EnvCellTransparentRoute.None;
+
+ EnvCellTransparentRoute routes = EnvCellTransparentRoute.None;
+ foreach ((ulong gfxObjId, List transforms) in groups)
+ {
+ if (transforms.Count == 0)
+ continue;
+ ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId);
+ if (renderData is null || renderData.IsSetup)
+ continue;
+ for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
+ {
+ ObjectRenderBatch batch = renderData.Batches[batchIndex];
+ if (batch.IsTransparent)
+ routes |= RouteTransparentBatch(batch, detailSurfaceActive);
+ }
+ }
+ return routes;
+ }
+ }
+
+ ///
+ /// Exact per-subset EnvCell route. Canonical installed-DAT witness
+ /// F4180104 / surface 08000BFF retains mask 0x08 and therefore reaches
+ /// CLIP when environment detail is absent; alpha-family mask 0x02 reaches
+ /// ALPHA. No collapsed value
+ /// participates in this decision.
+ ///
+ internal static EnvCellTransparentRoute RouteTransparentBatch(
+ ObjectRenderBatch batch,
+ bool detailSurfaceActive)
+ {
+ RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
+ currentlyDrawingSky: false,
+ delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
+ detailSurfaceActive: detailSurfaceActive,
+ multiPassAlpha: false,
+ subsetMask: batch.RetailSurfaceMask,
+ materialHasAlpha: false);
+ return decision.Action switch
+ {
+ RetailAlphaMeshAction.Immediate => EnvCellTransparentRoute.Immediate,
+ RetailAlphaMeshAction.Append => decision.List == RetailAlphaList.Clip
+ ? EnvCellTransparentRoute.Clip
+ : EnvCellTransparentRoute.Alpha,
+ RetailAlphaMeshAction.AppendClipAndImmediate =>
+ EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Immediate,
+ _ => throw new ArgumentOutOfRangeException(nameof(decision.Action)),
+ };
+ }
+
+ private static bool MatchesTransparentRoute(
+ ObjectRenderBatch batch,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive) =>
+ (RouteTransparentBatch(batch, detailSurfaceActive) & route) != 0;
+
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
@@ -1074,7 +1183,9 @@ public sealed partial class EnvCellRenderer :
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
List allInstances,
IReadOnlyList drawCallRanges,
- WbRenderPass renderPass)
+ WbRenderPass renderPass,
+ EnvCellTransparentRoute transparentRoute,
+ bool detailSurfaceActive)
{
// WB BaseObjectRenderManager.cs:710-713:
if (drawCalls.Count == 0 || allInstances.Count == 0) return;
@@ -1115,6 +1226,13 @@ public sealed partial class EnvCellRenderer :
}
}
+ if (renderPass == WbRenderPass.Transparent
+ && transparentRoute != EnvCellTransparentRoute.All
+ && !MatchesTransparentRoute(batch, transparentRoute, detailSurfaceActive))
+ {
+ continue;
+ }
+
totalDraws++;
}
}
@@ -1171,6 +1289,13 @@ public sealed partial class EnvCellRenderer :
}
}
+ if (renderPass == WbRenderPass.Transparent
+ && transparentRoute != EnvCellTransparentRoute.All
+ && !MatchesTransparentRoute(batch, transparentRoute, detailSurfaceActive))
+ {
+ continue;
+ }
+
int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0);
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex];
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index f0fd378e..361fcce4 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -109,6 +109,14 @@ namespace AcDream.App.Rendering.Wb
public (int Width, int Height) TextureSize { get; set; }
public TextureFormat TextureFormat { get; set; }
public uint SurfaceId { get; set; }
+ ///
+ /// Retail D3DPolyRender::ConstructMesh @0x0059dfa0's exact
+ /// per-subset routing mask. The Content extraction preserves this on
+ /// TextureBatchData; S4-c2 carries it across the upload boundary
+ /// so EnvCell replay can choose CLIP versus ALPHA without reconstructing
+ /// the raw surface bits from .
+ ///
+ public byte RetailSurfaceMask { get; set; }
public TextureKey Key { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; }
@@ -2250,6 +2258,7 @@ namespace AcDream.App.Rendering.Wb
TextureIndex = textureIndex,
TextureSize = (format.Width, format.Height),
TextureFormat = format.Format,
+ RetailSurfaceMask = batch.RetailSurfaceMask,
Translucency = batch.Translucency,
IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive,
diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
index fc8ed406..0f032831 100644
--- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
@@ -1,45 +1,110 @@
+using System.Numerics;
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.Content;
+using Chorizite.Core.Render.Enums;
+using Microsoft.Extensions.Logging.Abstractions;
+using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.App.Tests.Rendering;
///
-/// S4-c2 fix round 1 (M6, contract C4):
-/// is the queue-facing half of an EnvCell transparent shell's detail-off
-/// submission — one token per CELL, replayed through a caller-supplied
-/// delegate (production wiring:
-/// )
-/// at whatever flush site the shared drains
-/// at. These tests exercise the REAL production class end-to-end against a
-/// real queue — only the render destination is faked (a delegate recording
-/// what it was asked to draw), so the GPU-backed EnvCellRenderer
-/// never needs to be constructed to pin the queue mechanics.
+/// S4-c2 final EnvCell production-dispatch pins: exact retained per-batch mask,
+/// one token per (cell,list), separate list-filtered replay sources, immediate
+/// detail turns, interleaving, and warmed allocation behavior.
///
public sealed class EnvCellAlphaDrawSourceTests
{
- /// Mutation check: if SubmitOrDrawTransparentCellShell
- /// drew the cell immediately instead of appending it when detail is off
- /// (the M1/M3 bug class this chunk fixes elsewhere), the drawn list
- /// would already contain an entry BEFORE Flush runs — the
- /// Assert.Empty(drawn) line fails against that mutation.
+ ///
+ /// Canonical F4180104 surface 08000BFF's pure Base1ClipMap mask 0x08
+ /// reaches CLIP while alpha-family 0x02 reaches ALPHA. Mutation check:
+ /// hardcoding MaskAlphaFamily in the production batch classifier makes
+ /// the first assertion report Alpha instead of Clip.
[Fact]
- public void PendingCellToken_IsDrainedAtTheFlushAndNeverBeforeIt()
+ public void RetainedPerBatchMask_RoutesClipAndAlphaExactly()
{
- var drawn = new List>();
- var source = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
- cells => drawn.Add(cells.ToArray()));
- var queue = new RetailAlphaQueue();
+ var clip = new ObjectRenderBatch { IsTransparent = true, RetailSurfaceMask = 0x08 };
+ var alpha = new ObjectRenderBatch { IsTransparent = true, RetailSurfaceMask = 0x02 };
- queue.BeginFrame();
- int token = source.AddPendingCellId(0x1234u);
- Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, token, false));
+ Assert.Equal(
+ EnvCellTransparentRoute.Clip,
+ EnvCellRenderer.RouteTransparentBatch(clip, detailSurfaceActive: false));
+ Assert.Equal(
+ EnvCellTransparentRoute.Alpha,
+ EnvCellRenderer.RouteTransparentBatch(alpha, detailSurfaceActive: false));
+ }
- Assert.Empty(drawn);
+ ///
+ /// A mixed cell creates one token in each list and neither set draws before
+ /// the real queue drain. CLIP drains first and receives only the CLIP
+ /// replay filter; ALPHA receives only ALPHA. Mutation checks: inverting the
+ /// detail-off immediate predicate makes Assert.Empty(log) fail;
+ /// removing the replay filter makes either route assertion fail.
+ [Fact]
+ public void MixedCell_DefersOneTokenPerList_AndReplayIsListFiltered()
+ {
+ using var fixture = new ProductionEnvCellFixture(
+ detailSurfaceActive: false,
+ 0x08,
+ 0x02);
+ EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
+ ProductionEnvCellFixture.CellId,
+ detailSurfaceActive: false);
- queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
+ fixture.Queue.BeginFrame();
+ RetailPViewPassExecutor.DispatchTransparentCellShell(
+ ProductionEnvCellFixture.CellId,
+ routes,
+ detailSurfaceActive: false,
+ fixture.Queue,
+ fixture.ClipSource,
+ fixture.AlphaSource,
+ fixture.ImmediateSink);
- IReadOnlyList singleDraw = Assert.Single(drawn);
- Assert.Equal(new uint[] { 0x1234u }, singleDraw);
- queue.EndFrame();
+ Assert.Equal(EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Alpha, routes);
+ Assert.Empty(fixture.Device.Calls.OfType());
+ Assert.Equal(1, fixture.Queue.ClipCount);
+ Assert.Equal(1, fixture.Queue.AlphaCount);
+
+ fixture.Queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
+
+ GpuRecordedMultiDrawIndirect[] draws =
+ [.. fixture.Device.Calls.OfType()];
+ Assert.Equal(2, draws.Length);
+ Assert.All(draws, static draw => Assert.Equal(1u, draw.DrawCount));
+ fixture.Queue.EndFrame();
+ }
+
+ [Fact]
+ public void DetailOn_DrawsAtCellTurnWithDetail_AndQueuesNothing()
+ {
+ using var fixture = new ProductionEnvCellFixture(
+ detailSurfaceActive: true,
+ 0x08);
+ EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
+ ProductionEnvCellFixture.CellId,
+ detailSurfaceActive: true);
+ fixture.Queue.BeginFrame();
+
+ RetailPViewPassExecutor.DispatchTransparentCellShell(
+ ProductionEnvCellFixture.CellId,
+ routes,
+ detailSurfaceActive: true,
+ fixture.Queue,
+ fixture.ClipSource,
+ fixture.AlphaSource,
+ fixture.ImmediateSink);
+
+ Assert.Equal(EnvCellTransparentRoute.Immediate, routes);
+ Assert.Equal(1, fixture.ImmediateSink.DrawCount);
+ Assert.True(fixture.ImmediateSink.LastDetailSurfaceActive);
+ Assert.Equal(2, fixture.Device.Calls.OfType().Count());
+ Assert.Equal(0, fixture.Queue.PendingCount);
+ fixture.Queue.EndFrame();
+ Assert.Equal(1, fixture.ImmediateSink.DrawCount);
}
/// Two cell tokens from the SAME ,
@@ -57,20 +122,260 @@ public sealed class EnvCellAlphaDrawSourceTests
public void ParticleAppendedBetweenTwoCellTokens_KeepsItsPositionInTheCombinedDrain()
{
var log = new List();
- var cellSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
- cells => log.Add($"cell:{string.Join(',', cells)}"));
+ var clipSource = Source(EnvCellTransparentRoute.Clip, log);
+ var cellSource = Source(EnvCellTransparentRoute.Alpha, log);
var particleSource = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
- int cell1Token = cellSource.AddPendingCellId(0x100u);
- Assert.True(queue.TryAppend(RetailAlphaList.Alpha, cellSource, cell1Token, false));
+ RetailPViewPassExecutor.DispatchTransparentCellShell(
+ 0x100u, EnvCellTransparentRoute.Alpha, false, queue,
+ clipSource, cellSource, (_, _, _) => throw new InvalidOperationException());
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, particleSource, 7, false));
- int cell2Token = cellSource.AddPendingCellId(0x200u);
- Assert.True(queue.TryAppend(RetailAlphaList.Alpha, cellSource, cell2Token, false));
+ RetailPViewPassExecutor.DispatchTransparentCellShell(
+ 0x200u, EnvCellTransparentRoute.Alpha, false, queue,
+ clipSource, cellSource, (_, _, _) => throw new InvalidOperationException());
queue.EndFrame();
- Assert.Equal(new[] { "cell:256", "particle:7", "cell:512" }, log);
+ Assert.Equal(new[] { "Alpha:00000100", "particle:7", "Alpha:00000200" }, log);
+ }
+
+ [Fact]
+ public void ProductionDispatch_WarmedImmediateAndFilteredSourcesDoNotAllocate()
+ {
+ var sink = new NoAllocRouteSink();
+ var clipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
+ sink.Render,
+ EnvCellTransparentRoute.Clip);
+ var alphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
+ sink.Render,
+ EnvCellTransparentRoute.Alpha);
+ RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate = sink.DrawImmediate;
+ var queue = new RetailAlphaQueue();
+
+ for (int i = 0; i < 64; i++)
+ RunMixed(queue, clipSource, alphaSource, immediate);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int i = 0; i < 1_000; i++)
+ RunMixed(queue, clipSource, alphaSource, immediate);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0, allocated);
+ }
+
+ private static void RunMixed(
+ RetailAlphaQueue queue,
+ RetailPViewPassExecutor.EnvCellAlphaDrawSource clipSource,
+ RetailPViewPassExecutor.EnvCellAlphaDrawSource alphaSource,
+ RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate)
+ {
+ queue.BeginFrame();
+ RetailPViewPassExecutor.DispatchTransparentCellShell(
+ 0xF4180104u,
+ EnvCellTransparentRoute.Immediate
+ | EnvCellTransparentRoute.Clip
+ | EnvCellTransparentRoute.Alpha,
+ detailSurfaceActive: false,
+ queue,
+ clipSource,
+ alphaSource,
+ immediate);
+ queue.EndFrame();
+ }
+
+ private static RetailPViewPassExecutor.EnvCellAlphaDrawSource Source(
+ EnvCellTransparentRoute expectedRoute,
+ List log) =>
+ new(
+ (cells, route, detail) =>
+ {
+ Assert.Equal(expectedRoute, route);
+ Assert.False(detail);
+ for (int i = 0; i < cells.Count; i++)
+ log.Add($"{route}:{cells[i]:X8}");
+ },
+ expectedRoute);
+
+ ///
+ /// Recording-GPU fixture for the production EnvCell scan, dispatch and
+ /// filtered replay path. The two retained masks enter through the real
+ /// Content upload boundary, not through hand-built App batches.
+ ///
+ private sealed class ProductionEnvCellFixture : IDisposable
+ {
+ public const uint CellId = 0xF4180104u;
+ private const ulong MeshId = 0x2_F4180104UL;
+
+ private readonly GpuDeviceFrameLifetime _frames;
+ private readonly VulkanWorldPassScope _scope;
+ private readonly ObjectMeshManager _meshManager;
+ private readonly IGpuPassEncoder _pass;
+ private readonly IDisposable _publication;
+
+ public ProductionEnvCellFixture(
+ bool detailSurfaceActive,
+ params byte[] retainedMasks)
+ {
+ Device = new RecordingGpuDevice();
+ _frames = new GpuDeviceFrameLifetime(Device);
+ _scope = new VulkanWorldPassScope(sampleCount: 1);
+ _meshManager = new ObjectMeshManager(
+ new VulkanMeshPipelineDevice(Device.Retirement),
+ Device,
+ new NullPreparedAssetSource(),
+ NullLogger.Instance);
+
+ var mesh = new ObjectMeshData
+ {
+ ObjectId = MeshId,
+ Vertices =
+ [
+ new VertexPositionNormalTexture { Position = new Vector3(0, 0, 0) },
+ new VertexPositionNormalTexture { Position = new Vector3(1, 0, 0) },
+ new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) },
+ ],
+ };
+ var batches = new List(retainedMasks.Length);
+ for (int i = 0; i < retainedMasks.Length; i++)
+ {
+ batches.Add(new TextureBatchData
+ {
+ Key = new TextureKey { SurfaceId = 0x08000BFFu + (uint)i },
+ TextureData = new byte[8 * 8 * 4],
+ Indices = [0, 1, 2],
+ IsTransparent = true,
+ Translucency = AcDream.Core.Meshing.TranslucencyKind.AlphaBlend,
+ RetailSurfaceMask = retainedMasks[i],
+ CullMode = CullMode.Clockwise,
+ IsCellShell = true,
+ SourceSurfaceIndex = i,
+ });
+ }
+ mesh.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches;
+ Assert.NotNull(_meshManager.UploadMeshData(mesh));
+
+ Renderer = new EnvCellRenderer(
+ Device,
+ _frames,
+ _scope,
+ _meshManager,
+ new WbFrustum(),
+ detailSurfaceActive
+ ? new TerrainAtlas.RetailDetailTextureBinding(
+ new GpuTextureSlot(99),
+ Tiling: 2f,
+ SurfaceTextureId: 1,
+ RenderSurfaceId: 2,
+ Width: 4,
+ Height: 4)
+ : default,
+ detailSurfaceActive ? DetailOn : DetailOff);
+ typeof(EnvCellRenderer)
+ .GetField("_activeSnapshot", System.Reflection.BindingFlags.NonPublic
+ | System.Reflection.BindingFlags.Instance)!
+ .SetValue(Renderer, new EnvCellVisibilitySnapshot
+ {
+ BatchedByCell = new Dictionary>>
+ {
+ [CellId] = new Dictionary>
+ {
+ [MeshId] =
+ [
+ new InstanceData
+ {
+ Transform = Matrix4x4.Identity,
+ CellId = CellId,
+ },
+ ],
+ },
+ },
+ });
+
+ Queue = new RetailAlphaQueue();
+ ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
+ Renderer.RenderTransparentOrdered,
+ EnvCellTransparentRoute.Clip);
+ AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
+ Renderer.RenderTransparentOrdered,
+ EnvCellTransparentRoute.Alpha);
+ ImmediateSink = new ProductionImmediateSink(Renderer);
+
+ _frames.BeginFrame();
+ IGpuFrame frame = _frames.CurrentFrame!;
+ _pass = frame.BeginPass(
+ GpuPassDescription.BackbufferClear(
+ "s4-c2-envcell-production",
+ Vector4.Zero,
+ sampleCount: 1));
+ _publication = _scope.Publish(_pass);
+ Renderer.BeginFrame(frameSlot: 0);
+ Device.Clear();
+ }
+
+ public RecordingGpuDevice Device { get; }
+
+ public EnvCellRenderer Renderer { get; }
+
+ public RetailAlphaQueue Queue { get; }
+
+ public RetailPViewPassExecutor.EnvCellAlphaDrawSource ClipSource { get; }
+
+ public RetailPViewPassExecutor.EnvCellAlphaDrawSource AlphaSource { get; }
+
+ public ProductionImmediateSink ImmediateSink { get; }
+
+ private static bool DetailOn() => true;
+
+ private static bool DetailOff() => false;
+
+ public void Dispose()
+ {
+ _publication.Dispose();
+ _pass.Dispose();
+ Renderer.Dispose();
+ _meshManager.Dispose();
+ Device.Dispose();
+ }
+ }
+
+ private sealed class ProductionImmediateSink(EnvCellRenderer renderer) :
+ IEnvCellImmediateDrawSink
+ {
+ public int DrawCount { get; private set; }
+
+ public bool LastDetailSurfaceActive { get; private set; }
+
+ public void DrawImmediate(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive)
+ {
+ DrawCount++;
+ LastDetailSurfaceActive = detailSurfaceActive;
+ renderer.RenderTransparentOrdered([cellId], route, detailSurfaceActive);
+ }
+ }
+
+ private sealed class NullPreparedAssetSource : IPreparedAssetSource
+ {
+ public PreparedAssetSourceStats Stats => default;
+
+ public CacheStats DecodedTextureCacheStats => default;
+
+ public PreparedAssetPresence Probe(
+ AcDream.Content.Pak.PakAssetType type,
+ uint sourceFileId) =>
+ PreparedAssetPresence.Missing;
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default) =>
+ PreparedAssetReadResult.Missing;
+
+ public void Dispose()
+ {
+ }
}
private sealed class RecordingSource(string name, List log) : IRetailAlphaDrawSource
@@ -89,4 +394,21 @@ public sealed class EnvCellAlphaDrawSourceTests
{
}
}
+
+ private sealed class NoAllocRouteSink
+ {
+ public void Render(
+ IReadOnlyList cells,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive)
+ {
+ }
+
+ public void DrawImmediate(
+ uint cellId,
+ EnvCellTransparentRoute route,
+ bool detailSurfaceActive)
+ {
+ }
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
index 7ca79a5c..dd50f571 100644
--- a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
@@ -1,16 +1,23 @@
+using System.Numerics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.Content;
using AcDream.Core.Meshing;
+using AcDream.Core.Vfx;
+using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering;
///
-/// S4-c2 fix round 1 (M1, blocking):
-/// is the pure router-input derivation the blocking fix extracted from
-/// DeferToRetailAlphaQueue so it is directly testable without a GPU/
-/// mesh-manager harness. Before this round, a mesh-particle batch classified
-/// (mask 0x00) reaching DrawMesh
-/// row 5 (Immediate) hit an unconditional throw in the render loop — these
-/// tests pin the two outcomes the fix made reachable.
+/// S4-c2 particle routing and production-dispatch pins. The pure table tests
+/// remain narrow coverage; the two principal cases below call the actual
+/// dispatch that the
+/// renderer's scene loop uses.
///
public sealed class ParticleRendererRouteTests
{
@@ -24,16 +31,37 @@ public sealed class ParticleRendererRouteTests
/// derivation to a hardcoded `false` makes this assertion fail because
/// the decision becomes Immediate instead of Append.
[Fact]
- public void OpaqueClassifiedMeshBatch_WithMaterialAlpha_RoutesToAlphaAppend()
+ public void OpaqueClassifiedMeshBatch_WithMaterialAlpha_DefersAndDrawsOnlyAtFlush()
{
- // Top byte (alpha) = 0x80 -> not 0xFF -> materialHasAlpha = true.
- const uint colorArgbWithAlpha = 0x80FFFFFFu;
+ var queue = new RetailAlphaQueue();
+ var source = new RecordingSource();
+ int reserveCount = 0;
+ int immediateCount = 0;
+ queue.BeginFrame();
- RetailAlphaMeshDecision decision = ParticleRenderer.RouteParticleSubmission(
- ParticleSubmissionKind.Mesh, TranslucencyKind.Opaque, colorArgbWithAlpha);
+ const uint colorArgbWithAlpha = 0x80FFFFFFu;
+ RetailAlphaMeshDecision decision = ParticleRenderer.DeferToRetailAlphaQueue(
+ ParticleSubmissionKind.Mesh,
+ TranslucencyKind.Opaque,
+ colorArgbWithAlpha,
+ queue,
+ source,
+ reserveDeferred: () => reserveCount++,
+ drawImmediate: (_, _, _, _) => immediateCount++,
+ Matrix4x4.Identity,
+ drawIndex: 7);
Assert.Equal(RetailAlphaMeshAction.Append, decision.Action);
Assert.Equal(RetailAlphaList.Alpha, decision.List);
+ Assert.Equal(1, reserveCount);
+ Assert.Equal(1, queue.AlphaCount);
+ Assert.Equal(0, source.DrawCount);
+ Assert.Equal(0, immediateCount);
+
+ queue.EndFrame();
+
+ Assert.Equal(1, source.DrawCount);
+ Assert.Equal(0, immediateCount);
}
/// M1(d) pin 2: the SAME Opaque-classified mesh-particle batch
@@ -47,14 +75,100 @@ public sealed class ParticleRendererRouteTests
/// as a failure (an unhandled exception) rather than an assertion
/// mismatch.
[Fact]
- public void OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_RoutesImmediate()
+ public void OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_DrawsImmediateOnOpaqueDepthState()
{
- const uint fullyOpaqueColorArgb = 0xFFFFFFFFu;
+ var queue = new RetailAlphaQueue();
+ var source = new RecordingSource();
+ int reserveCount = 0;
+ var immediate = new List<(ParticleSubmissionKind Kind, int Index, bool Opaque)>();
+ queue.BeginFrame();
- RetailAlphaMeshDecision decision = ParticleRenderer.RouteParticleSubmission(
- ParticleSubmissionKind.Mesh, TranslucencyKind.Opaque, fullyOpaqueColorArgb);
+ const uint fullyOpaqueColorArgb = 0xFFFFFFFFu;
+ RetailAlphaMeshDecision decision = ParticleRenderer.DeferToRetailAlphaQueue(
+ ParticleSubmissionKind.Mesh,
+ TranslucencyKind.Opaque,
+ fullyOpaqueColorArgb,
+ queue,
+ source,
+ reserveDeferred: () => reserveCount++,
+ drawImmediate: (_, kind, index, opaque) => immediate.Add((kind, index, opaque)),
+ Matrix4x4.Identity,
+ drawIndex: 11);
Assert.Equal(RetailAlphaMeshAction.Immediate, decision.Action);
+ Assert.Equal(0, reserveCount);
+ Assert.Equal(0, queue.PendingCount);
+ Assert.Equal(
+ [(ParticleSubmissionKind.Mesh, 11, true)],
+ immediate);
+
+ ParticleRenderer.MeshParticlePipelineState state =
+ ParticleRenderer.ResolveMeshParticlePipelineState(
+ TranslucencyKind.Opaque,
+ opaqueDepthState: immediate[0].Opaque);
+ Assert.Equal(GpuBlendMode.None, state.Blend);
+ Assert.True(state.Depth.Test);
+ Assert.True(state.Depth.Write);
+ Assert.Equal(WorldDepthContract.WorldCompare, state.Depth.Compare);
+
+ queue.EndFrame();
+ Assert.Equal(0, source.PrepareCount);
+ Assert.Equal(0, source.DrawCount);
+ }
+
+ ///
+ /// M1 production fixed-state/binding pin. Executes the real private
+ /// pipeline factory against the recording GPU device and the real
+ /// PipelineForMeshBlend selector (not a restated expected-state
+ /// helper). Mutation checks: making the opaque factory depth-write false
+ /// fails the descriptor assertions; mapping the immediate opaque case to
+ /// _meshAlphaPipeline fails the identity assertion.
+ ///
+ [Fact]
+ public void ImmediateOpaqueMesh_UsesProductionParticleMeshOpaquePipelineDescription()
+ {
+ using var device = new RecordingGpuDevice();
+ using var manager = new ObjectMeshManager(
+ new VulkanMeshPipelineDevice(device.Retirement),
+ device,
+ new NullPreparedAssetSource(),
+ NullLogger.Instance);
+ var adapter = (WbMeshAdapter)RuntimeHelpers.GetUninitializedObject(
+ typeof(WbMeshAdapter));
+ typeof(WbMeshAdapter).GetField(
+ "_meshManager",
+ BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(adapter, manager);
+ var frames = new GpuDeviceFrameLifetime(device);
+ var scope = new VulkanWorldPassScope(sampleCount: 1);
+ using var renderer = new ParticleRenderer(
+ device,
+ frames,
+ scope,
+ new ParticleSystem(new EmitterDescRegistry(), new Random(42)),
+ meshAdapter: adapter);
+ MethodInfo select = typeof(ParticleRenderer).GetMethod(
+ "PipelineForMeshBlend",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ var selected = (IGpuPipeline)select.Invoke(
+ renderer,
+ [TranslucencyKind.Opaque, true])!;
+
+ Assert.Same(
+ device.CreatedPipelines.Single(
+ static pipeline => pipeline.Description.Name == "particle-mesh-opaque"),
+ selected);
+ GpuPipelineDescription description = selected.Description;
+ Assert.Equal("particle_mesh", description.Shaders.Name);
+ Assert.Equal(GpuBlendMode.None, description.Blend);
+ Assert.True(description.Depth.Test);
+ Assert.True(description.Depth.Write);
+ Assert.Equal(WorldDepthContract.WorldCompare, description.Depth.Compare);
+ Assert.Equal(GpuCullMode.None, description.Cull);
+ Assert.Equal(GpuFrontFace.Clockwise, description.FrontFace);
+ Assert.False(description.AlphaToCoverage);
+
}
/// A billboard submission always carries the alpha-family mask
@@ -73,4 +187,114 @@ public sealed class ParticleRendererRouteTests
Assert.Equal(RetailAlphaMeshAction.Append, decision.Action);
Assert.Equal(RetailAlphaList.Alpha, decision.List);
}
+
+ [Fact]
+ public void ProductionDispatch_WarmedAppendAndImmediatePathsDoNotAllocate()
+ {
+ var queue = new RetailAlphaQueue();
+ var sink = new NoAllocParticleSink();
+ ParticleRenderer.ReserveDeferredParticleDraw reserve = sink.Reserve;
+ ParticleRenderer.DrawImmediateParticle immediate = sink.DrawImmediate;
+
+ for (int i = 0; i < 64; i++)
+ RunPair(queue, sink, reserve, immediate);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int i = 0; i < 1_000; i++)
+ RunPair(queue, sink, reserve, immediate);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0, allocated);
+ }
+
+ private static void RunPair(
+ RetailAlphaQueue queue,
+ NoAllocParticleSink sink,
+ ParticleRenderer.ReserveDeferredParticleDraw reserve,
+ ParticleRenderer.DrawImmediateParticle immediate)
+ {
+ queue.BeginFrame();
+ ParticleRenderer.DeferToRetailAlphaQueue(
+ ParticleSubmissionKind.Mesh,
+ TranslucencyKind.Opaque,
+ 0x80FFFFFFu,
+ queue,
+ sink,
+ reserve,
+ immediate,
+ Matrix4x4.Identity,
+ drawIndex: 0);
+ ParticleRenderer.DeferToRetailAlphaQueue(
+ ParticleSubmissionKind.Mesh,
+ TranslucencyKind.Opaque,
+ 0xFFFFFFFFu,
+ queue,
+ sink,
+ reserve,
+ immediate,
+ Matrix4x4.Identity,
+ drawIndex: 1);
+ queue.EndFrame();
+ }
+
+ private sealed class RecordingSource : IRetailAlphaDrawSource
+ {
+ public int PrepareCount { get; private set; }
+ public int DrawCount { get; private set; }
+
+ public void PrepareAlphaDraws(ReadOnlySpan tokens) => PrepareCount++;
+
+ public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) =>
+ DrawCount += drawCount;
+
+ public void ResetAlphaSubmissions()
+ {
+ }
+ }
+
+ private sealed class NoAllocParticleSink : IRetailAlphaDrawSource
+ {
+ private int _nextToken;
+
+ public int Reserve() => _nextToken++;
+
+ public void DrawImmediate(
+ Matrix4x4 viewProjection,
+ ParticleSubmissionKind kind,
+ int drawIndex,
+ bool opaqueDepthState)
+ {
+ }
+
+ public void PrepareAlphaDraws(ReadOnlySpan tokens)
+ {
+ }
+
+ public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
+ {
+ }
+
+ public void ResetAlphaSubmissions() => _nextToken = 0;
+ }
+
+ private sealed class NullPreparedAssetSource : IPreparedAssetSource
+ {
+ public PreparedAssetSourceStats Stats => default;
+
+ public CacheStats DecodedTextureCacheStats => default;
+
+ public PreparedAssetPresence Probe(
+ AcDream.Content.Pak.PakAssetType type,
+ uint sourceFileId) =>
+ PreparedAssetPresence.Missing;
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default) =>
+ PreparedAssetReadResult.Missing;
+
+ public void Dispose()
+ {
+ }
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
index 6ecb5de6..39897130 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
@@ -289,11 +289,77 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(RetailAlphaQueue.ListCapacity, queue.PendingCount);
queue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
- // The dropped 3001st token (3000) never reaches the source at all —
- // only the 3000 accepted entries drew.
+ // The dropped 3001st token (3000) is never prepared or drawn — only
+ // the 3000 accepted entries draw. Its source still participates in
+ // cleanup; the two-source pin below discriminates that case.
Assert.Equal(RetailAlphaQueue.ListCapacity, source.LastDrawCount);
}
+ ///
+ /// S4-c2 final fix R2-3: production callers reserve source-owned payload
+ /// before TryAppend. If source A already fills ALPHA, source B's
+ /// FIRST append is rejected but B must still be registered for cleanup.
+ /// It is never prepared/drawn, is reset exactly once by every terminal
+ /// path, and starts the next frame empty. Mutation check: moving
+ /// RegisterSource(source) below the capacity return makes the first
+ /// Assert.Equal(1, rejected.ResetCount) fail with actual 0.
+ [Theory]
+ [InlineData("flush")]
+ [InlineData("end")]
+ [InlineData("abort")]
+ public void RejectedFirstUseSource_IsCleanedWithoutPrepareOrDraw(string terminal)
+ {
+ var filling = new CountingSource();
+ var rejected = new RetainedPayloadSource();
+ var queue = new RetailAlphaQueue();
+
+ queue.BeginFrame();
+ for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
+ Assert.True(queue.TryAppend(RetailAlphaList.Alpha, filling, i, false));
+
+ int rejectedToken = rejected.Reserve(91);
+ Assert.False(queue.TryAppend(
+ RetailAlphaList.Alpha,
+ rejected,
+ rejectedToken,
+ overrideClipmap: false));
+
+ switch (terminal)
+ {
+ case "flush":
+ queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
+ queue.AbortFrame();
+ break;
+ case "end":
+ queue.EndFrame();
+ break;
+ case "abort":
+ queue.AbortFrame();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(terminal));
+ }
+
+ Assert.Equal(0, rejected.PrepareCount);
+ Assert.Equal(0, rejected.DrawCount);
+ Assert.Equal(1, rejected.ResetCount);
+ Assert.Equal(0, rejected.PendingCount);
+
+ queue.BeginFrame();
+ int acceptedToken = rejected.Reserve(92);
+ Assert.True(queue.TryAppend(
+ RetailAlphaList.Alpha,
+ rejected,
+ acceptedToken,
+ overrideClipmap: false));
+ queue.EndFrame();
+
+ Assert.Equal(1, rejected.PrepareCount);
+ Assert.Equal(1, rejected.DrawCount);
+ Assert.Equal(2, rejected.ResetCount);
+ Assert.Equal(0, rejected.PendingCount);
+ }
+
/// The ALPHA list has its own independent capacity — filling
/// CLIP to capacity must not affect ALPHA appends.
[Fact]
@@ -513,6 +579,45 @@ public sealed class RetailAlphaQueueTests
public void ResetAlphaSubmissions() => ResetCount++;
}
+ private sealed class RetainedPayloadSource : IRetailAlphaDrawSource
+ {
+ private readonly List _pending = new();
+ private int[] _prepared = [];
+
+ public int PrepareCount { get; private set; }
+ public int DrawCount { get; private set; }
+ public int ResetCount { get; private set; }
+ public int PendingCount => _pending.Count;
+
+ public int Reserve(int value)
+ {
+ int token = _pending.Count;
+ _pending.Add(value);
+ return token;
+ }
+
+ public void PrepareAlphaDraws(ReadOnlySpan tokens)
+ {
+ PrepareCount++;
+ _prepared = new int[tokens.Length];
+ for (int i = 0; i < tokens.Length; i++)
+ _prepared[i] = _pending[tokens[i]];
+ }
+
+ public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
+ {
+ Assert.InRange(firstPreparedDraw, 0, _prepared.Length - drawCount);
+ DrawCount += drawCount;
+ }
+
+ public void ResetAlphaSubmissions()
+ {
+ ResetCount++;
+ _pending.Clear();
+ _prepared = [];
+ }
+ }
+
private sealed class FailureSource(
string? drawFailure,
string? resetFailure) : IRetailAlphaDrawSource
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index 61c0a188..30b436e6 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -160,23 +160,17 @@ public sealed class RetailPViewPassExecutorTests
}
///
- /// S4-c2 fix round 1 (M6, contract C4): a cell's transparent shell's
- /// immediate-vs-append decision must come from the SAME
- /// table every other alpha submitter
- /// uses — never a hand-coded if (detailEnabled) branch that
- /// bypasses it. Pins the STRUCTURE:
- /// is read to feed 's own
- /// detailSurfaceActive argument BEFORE the route call, and the
- /// route call precedes BOTH possible outcomes
- /// ( for
- /// Immediate, for Append).
- /// Mutation check: deleting the Route call and branching directly
- /// on TransparentDetailEnabled instead removes the
- /// call entirely — RequiredCallIndex
- /// fails its index >= 0 assertion for the route call.
+ /// S4-c2 fix round 2 (M6): supplemental structure pin linking the real
+ /// cell submitter to the retained-batch scan and the functional production
+ /// dispatcher proved by EnvCellAlphaDrawSourceTests. The per-subset
+ /// calls now correctly live inside
+ /// (one call per real
+ /// retained batch), not here once per whole cell. Mutation check: bypassing
+ /// that scan for a hand-coded cell-wide mask removes the required call and
+ /// fails before the dispatch-order assertion.
///
[Fact]
- public void SubmitOrDrawTransparentCellShell_RoutesThroughTheSharedTableBeforeEitherOutcome()
+ public void SubmitOrDrawTransparentCellShell_ScansRetainedBatchesBeforeProductionDispatch()
{
MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
"SubmitOrDrawTransparentCellShell",
@@ -185,16 +179,13 @@ public sealed class RetailPViewPassExecutorTests
int detailProbe = RequiredCallIndex(
calls, typeof(EnvCellRenderer), "get_TransparentDetailEnabled");
- int route = RequiredCallIndex(
- calls, typeof(RetailAlphaMeshRouter), nameof(RetailAlphaMeshRouter.Route));
- int renderImmediate = RequiredCallIndex(
- calls, typeof(EnvCellRenderer), nameof(EnvCellRenderer.RenderTransparentOrdered));
- int append = RequiredCallIndex(
- calls, typeof(RetailAlphaQueue), nameof(RetailAlphaQueue.TryAppend));
+ int scan = RequiredCallIndex(
+ calls, typeof(EnvCellRenderer), nameof(EnvCellRenderer.GetTransparentRoutes));
+ int dispatch = RequiredCallIndex(
+ calls, typeof(RetailPViewPassExecutor), nameof(RetailPViewPassExecutor.DispatchTransparentCellShell));
- Assert.True(detailProbe < route);
- Assert.True(route < renderImmediate);
- Assert.True(route < append);
+ Assert.True(detailProbe < scan);
+ Assert.True(scan < dispatch);
}
///
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
index bba935dd..db6d7b7b 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
@@ -89,15 +89,15 @@ public static class WalkAlphaDepthTrace
// (alphaedMeshCountAlpha/List) — Ghidra-verified 2026-09-04, see
// RetailAlphaList's own doc comment. "new" is retail's per-entry
// first-for-list flag (param_4); "clip" (param_5, unused here)
- // is overrideClipmap. S4-c2 fix round 1 (M4/A6): IsNew is parsed
- // and RETAINED purely as a captured/parsed FACT (fidelity to the
- // capture's own format) but is never compared against acdream's
- // own routing — M4 established that flag is trivially true for
- // EVERY subset retail appends (one DrawMesh invocation owns one
- // subset per list), so a per-subset comparison would be
- // meaningless at acdream's coarser per-INSTANCE granularity (the
- // same content-volume mismatch M5's KnownFailure count gate
- // documents) — no dead-parse-as-evidence claim is made here.
+ // is overrideClipmap. S4-c2 fix round 2: DrawMesh sets one flag
+ // per list at entry and clears that list's flag after its first
+ // successful append; later subsets in the same call can therefore
+ // carry new=0 (the capture has 9,685 true and 871 false). IsNew is
+ // retained as a capture fact but is not compared to acdream: its
+ // retained Wb/particle/EnvCell submission units are not one-to-one
+ // DrawMesh invocations, and Vulkan has no consumer for retail's
+ // material-capture optimization bit. The KnownFailure count gate
+ // below remains about zero replay content, not this parsed field.
Match am = AmPattern.Match(line);
if (am.Success)
{
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
index bd17cfaa..17121936 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
@@ -594,6 +594,151 @@ public sealed class WalkStaticStreamPopulatorTests
fx.AlphaQueue.AbortFrame();
}
+ ///
+ /// S4-c2 final M3 production pin for SubmitWalkAlphaInstance:
+ /// building shell + detail ON executes the immediate base+detail draws at
+ /// the submit mark and queues nothing; detail OFF queues, then drains the
+ /// base draw without a detail pass; an ordinary batch remains deferred
+ /// even while the global detail option is on. Mutation check: hardcoding
+ /// detailSurfaceActive=false at the production submit makes the
+ /// first case's pending-count assertion fail with 1.
+ [Fact]
+ public void SubmitWalkAlphaInstance_DetailOnOffAndOrdinaryUseProductionOutcomes()
+ {
+ using (var on = new DispatcherFixture(
+ withAlphaQueue: true,
+ detailAvailable: true,
+ detailEnabled: true))
+ using (DrawScope draw = on.BeginDraw(beginAlpha: true))
+ {
+ WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
+ on.Dispatcher.SubmitWalkAlphaInstance(
+ in building,
+ Vector3.Zero,
+ Matrix4x4.Identity);
+
+ Assert.Equal(0, on.AlphaQueue!.PendingCount);
+ Assert.Contains(
+ on.Device.Calls.OfType(),
+ call => call.PipelineName.Contains("alpha", StringComparison.Ordinal)
+ && !call.PipelineName.Contains("detail", StringComparison.Ordinal));
+ Assert.Contains(
+ on.Device.Calls.OfType(),
+ call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
+ }
+
+ using (var off = new DispatcherFixture(
+ withAlphaQueue: true,
+ detailAvailable: true,
+ detailEnabled: false))
+ using (DrawScope draw = off.BeginDraw(beginAlpha: true))
+ {
+ WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
+ off.Dispatcher.SubmitWalkAlphaInstance(
+ in building,
+ Vector3.Zero,
+ Matrix4x4.Identity);
+
+ Assert.Equal(1, off.AlphaQueue!.PendingCount);
+ Assert.Empty(off.Device.Calls.OfType());
+ off.AlphaQueue.EndFrame();
+ Assert.DoesNotContain(
+ off.Device.Calls.OfType(),
+ call => call.PipelineName.Contains("retail-detail", StringComparison.Ordinal));
+ }
+
+ using (var ordinary = new DispatcherFixture(
+ withAlphaQueue: true,
+ detailAvailable: true,
+ detailEnabled: true))
+ using (DrawScope draw = ordinary.BeginDraw(beginAlpha: true))
+ {
+ WbDrawDispatcher.WalkClassifiedBatch batch = AlphaWalkBatch(detailCategory: 0u);
+ ordinary.Dispatcher.SubmitWalkAlphaInstance(
+ in batch,
+ Vector3.Zero,
+ Matrix4x4.Identity);
+
+ Assert.Equal(1, ordinary.AlphaQueue!.PendingCount);
+ Assert.Empty(ordinary.Device.Calls.OfType());
+ }
+ }
+
+ ///
+ /// The sibling grouped-production site executes the same detail-on row 1,
+ /// not merely the walk submit site. This invokes the real private
+ /// DeferTransparentGroups after seeding its real retained group
+ /// scratch. The same hardcoded-false mutation leaves one queued token and
+ /// makes the pending-count assertion fail.
+ [Fact]
+ public void DeferTransparentGroups_BuildingDetailOnDrawsImmediateWithDetail()
+ {
+ using var fx = new DispatcherFixture(
+ withAlphaQueue: true,
+ detailAvailable: true,
+ detailEnabled: true);
+ using DrawScope draw = fx.BeginDraw(beginAlpha: true);
+ WbDrawDispatcher.InstanceGroup group = AlphaInstanceGroup(detailCategory: 1u);
+ FieldInfo field = typeof(WbDrawDispatcher).GetField(
+ "_translucentDraws",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ var groups = (List)field.GetValue(fx.Dispatcher)!;
+ groups.Add(group);
+ MethodInfo defer = typeof(WbDrawDispatcher).GetMethod(
+ "DeferTransparentGroups",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ defer.Invoke(fx.Dispatcher, [Vector3.Zero, Matrix4x4.Identity]);
+
+ Assert.Equal(0, fx.AlphaQueue!.PendingCount);
+ Assert.Contains(
+ fx.Device.Calls.OfType(),
+ call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
+ }
+
+ private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(uint detailCategory) =>
+ new(
+ new GroupKey(
+ 0,
+ 0,
+ 3,
+ new GpuTextureSlot(1),
+ 0,
+ TranslucencyKind.AlphaBlend,
+ FoliageFlags: 0),
+ Matrix4x4.Identity,
+ ClipSlot: 0,
+ WbDrawDispatcher.InstanceLightSet.Disabled,
+ IndoorFlag: 0,
+ Alpha: 1f,
+ SelectionLighting: Vector2.Zero,
+ DetailCategory: detailCategory,
+ IsOpaque: false,
+ LocalSortCenter: Vector3.Zero);
+
+ private static WbDrawDispatcher.InstanceGroup AlphaInstanceGroup(uint detailCategory)
+ {
+ var group = new WbDrawDispatcher.InstanceGroup
+ {
+ FirstIndex = 0,
+ BaseVertex = 0,
+ IndexCount = 3,
+ TextureSlot = new GpuTextureSlot(1),
+ TextureLayer = 0,
+ Translucency = TranslucencyKind.AlphaBlend,
+ };
+ group.Matrices.Add(Matrix4x4.Identity);
+ group.LocalSortCenters.Add(Vector3.Zero);
+ group.SubmissionOrders.Add(0);
+ group.Slots.Add(0);
+ group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
+ group.IndoorFlags.Add(0);
+ group.DetailCategories.Add(detailCategory);
+ group.Opacities.Add(1f);
+ group.SelectionLighting.Add(Vector2.Zero);
+ return group;
+ }
+
[Fact]
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
{
@@ -685,7 +830,9 @@ public sealed class WalkStaticStreamPopulatorTests
public DispatcherFixture(
bool withAlphaQueue = false,
- IRetailSelectionRenderSink? selectionSink = null)
+ IRetailSelectionRenderSink? selectionSink = null,
+ bool detailAvailable = false,
+ bool detailEnabled = false)
{
Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device);
@@ -712,7 +859,17 @@ public sealed class WalkStaticStreamPopulatorTests
new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager(),
selectionSink: selectionSink,
- alphaQueue: AlphaQueue);
+ alphaQueue: AlphaQueue,
+ buildingDetail: detailAvailable
+ ? new TerrainAtlas.RetailDetailTextureBinding(
+ new GpuTextureSlot(99),
+ Tiling: 2f,
+ SurfaceTextureId: 1,
+ RenderSurfaceId: 2,
+ Width: 4,
+ Height: 4)
+ : default,
+ buildingDetailEnabled: detailEnabled ? DetailOn : DetailOff);
}
public RecordingGpuDevice Device { get; }
@@ -727,8 +884,13 @@ public sealed class WalkStaticStreamPopulatorTests
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
- public DrawScope BeginDraw()
+ public DrawScope BeginDraw(bool beginAlpha = false)
{
+ if (beginAlpha)
+ {
+ Dispatcher.BeginFrame(frameSlot: 0);
+ AlphaQueue!.BeginFrame();
+ }
FrameLifetime.BeginFrame();
IGpuFrame frame = FrameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
@@ -739,6 +901,10 @@ public sealed class WalkStaticStreamPopulatorTests
return new DrawScope(frame, pass, publication);
}
+ private static bool DetailOn() => true;
+
+ private static bool DetailOff() => false;
+
public void Dispose()
{
Dispatcher.Dispose();
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index ebb3ca3d..ba85ee06 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -1,16 +1,30 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Packs;
+using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Walk;
+using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.Tests.Architecture;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.Content;
+using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.World;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Lib.IO;
+using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering;
@@ -513,6 +527,38 @@ public sealed class WorldSceneRendererTests
}
}
+ ///
+ /// S4-c2 fix round 2 (A1): this is the positive, concrete owner-path
+ /// proof. It opens the real recording GPU world scope, executes
+ /// for an outdoor root containing one real
+ /// walk building, then lets close the
+ /// SAME through EndFrame. Only
+ /// drains that pass retail's threshold gate are observed. The production
+ /// order is therefore the building's own zero-threshold barrier followed
+ /// by the final RenderNormalMode drain; an outdoor LandscapeFlush is
+ /// forbidden. Restoring the removed outdoor
+ /// passes.FlushLandscapeAlpha() call inserts LandscapeFlush between
+ /// those two entries. Moving/deleting _alpha.EndFrame() removes or
+ /// reorders RenderNormalMode. The IL pin in
+ /// RetailPViewRendererOutdoorFlushTests remains secondary only.
+ ///
+ [Fact]
+ public void OutdoorProductionPView_DrainsBuildingThenRenderNormalModeWithoutLandscapeFlush()
+ {
+ var drains = new List();
+ using var fixture = new OutdoorAlphaOwnerFixture(drains.Add);
+
+ WorldRenderFrameOutcome outcome = fixture.Renderer.Render(default);
+
+ Assert.True(outcome.NormalWorldDrawn);
+ Assert.Equal(1, fixture.PView.DrawCount);
+ Assert.Equal(
+ [RetailAlphaFlushSite.DrawBuilding, RetailAlphaFlushSite.RenderNormalMode],
+ drains);
+ Assert.DoesNotContain(RetailAlphaFlushSite.LandscapeFlush, drains);
+ }
+
private sealed class Rig
{
public Rig(
@@ -603,6 +649,241 @@ public sealed class WorldSceneRendererTests
public WorldSceneRenderer Renderer { get; }
}
+ private sealed class OutdoorAlphaOwnerFixture : IDisposable
+ {
+ private readonly RecordingGpuDevice _device;
+ private readonly TextureCache _textures;
+ private readonly WbMeshAdapter _meshAdapter;
+ private readonly WbDrawDispatcher _dispatcher;
+ private readonly EnvCellRenderer _envCells;
+ private readonly ClipFrame _clipFrame;
+ private readonly RenderSceneShadowRuntime _renderScene;
+ private readonly IGpuPassEncoder _pass;
+ private readonly IDisposable _scopePublication;
+
+ public OutdoorAlphaOwnerFixture(Action observeDrain)
+ {
+ _device = new RecordingGpuDevice();
+ var frames = new GpuDeviceFrameLifetime(_device);
+ var scope = new FixedWorldPassScope();
+ var dat = new NoopDatReaderWriter();
+ var prepared = new NullPreparedAssetSource();
+ _textures = new TextureCache(_device, dat);
+ _meshAdapter = new WbMeshAdapter(
+ _device,
+ dat,
+ prepared,
+ NullLogger.Instance,
+ _device.Retirement);
+ var spawns = new EntitySpawnAdapter(
+ _textures,
+ _ => throw new NotSupportedException("The empty owner-path fixture never spawns entities."));
+ var alpha = new RetailAlphaQueue(drainObserver: observeDrain);
+ _dispatcher = new WbDrawDispatcher(
+ _device,
+ frames,
+ scope,
+ _textures,
+ _meshAdapter,
+ spawns,
+ new EntityClassificationCache(),
+ new AcDream.Core.Rendering.TranslucencyFadeManager(),
+ alphaQueue: alpha);
+ _envCells = new EnvCellRenderer(
+ _device,
+ frames,
+ scope,
+ _meshAdapter.MeshManager!,
+ new WbFrustum());
+ _clipFrame = ClipFrame.NoClip();
+
+ var glDiagnostics = new WorldRenderDiagnostics(
+ new DefaultGlStateReader(),
+ new NullDiagnosticLog());
+ var terrainDiagnostics = new TerrainDrawDiagnosticsController(
+ enabled: false,
+ glDiagnostics,
+ new EmptyFrameFacts(),
+ new NullDiagnosticLog());
+ var pviewPasses = new RetailPViewPassExecutor(
+ new NullWorldPassSurface(),
+ NullRenderFrameGlState.Instance,
+ _clipFrame,
+ terrain: null,
+ _envCells,
+ _dispatcher,
+ sky: null,
+ particles: null,
+ particleRenderer: null,
+ portalDepthMask: null,
+ alpha,
+ glDiagnostics,
+ terrainDiagnostics);
+
+ const uint landblockId = 0xF4180000u;
+ var building = new WalkBuilding
+ {
+ PositionCellId = landblockId | 1u,
+ DrawingBsp = new WalkBspNode { InPortals = [] },
+ };
+ var entry = new WalkBuildingFactory.Entry(
+ building,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity);
+ var buildings = new WalkBuildingRegistry();
+ buildings.Publish(landblockId, [entry]);
+ var landscape = new WalkLandscapeAssembler();
+ landscape.PublishLandblock(landblockId, maxZ: 10f, minZ: -1f, [entry]);
+ var cells = new CellVisibility();
+ _renderScene = new RenderSceneShadowRuntime(RenderSceneGeneration.FromRaw(1));
+ var retailPView = new RetailPViewRenderer(
+ _renderScene,
+ buildings,
+ landscape,
+ cells,
+ new ShadowObjectRegistry());
+ PView = new CountingPView(new WorldScenePViewRenderer(retailPView, pviewPasses));
+
+ frames.BeginFrame();
+ IGpuFrame frame = frames.CurrentFrame!;
+ _pass = frame.BeginPass(
+ GpuPassDescription.BackbufferClear(
+ "s4-c2-outdoor-owner-path",
+ Vector4.Zero,
+ sampleCount: 1));
+ _scopePublication = scope.Publish(_pass);
+ _dispatcher.BeginFrame(frameSlot: 0);
+ _envCells.BeginFrame(frameSlot: 0);
+
+ var calls = new List();
+ var root = new LoadedCell
+ {
+ CellId = landblockId | 1u,
+ IsOutdoorNode = true,
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ };
+ var day = new DayGroupData { Name = "s4-c2-owner" };
+ var foundation = new RenderFrameFoundation(
+ PortalViewportVisible: false,
+ Sky: default,
+ Atmosphere: default);
+ Renderer = new WorldSceneRenderer(
+ new FoundationSource(foundation),
+ new LoginSource(false),
+ new SkySource(day, dayFraction: 0f),
+ new FrameBuilder(calls, CreateFrame(root, playerSeenOutside: true)),
+ new EntitySource(),
+ selection: null,
+ alpha,
+ new ParticleVisibility(calls),
+ PView,
+ new PViewCells(),
+ new PassExecutor(calls),
+ new WorldRenderRangeState(nearRadius: 4, farRadius: 12),
+ new Diagnostics(calls));
+ }
+
+ public CountingPView PView { get; }
+
+ public WorldSceneRenderer Renderer { get; }
+
+ public void Dispose()
+ {
+ _scopePublication.Dispose();
+ _pass.Dispose();
+ _envCells.Dispose();
+ _dispatcher.Dispose();
+ _meshAdapter.Dispose();
+ _textures.Dispose();
+ _renderScene.Dispose();
+ _clipFrame.Dispose();
+ _device.Dispose();
+ }
+ }
+
+ private sealed class CountingPView(IWorldScenePViewRenderer inner) : IWorldScenePViewRenderer
+ {
+ public int DrawCount { get; private set; }
+
+ public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input)
+ {
+ DrawCount++;
+ return inner.DrawInside(input);
+ }
+
+ public void AbortFrame() => inner.AbortFrame();
+ }
+
+ private sealed class NullWorldPassSurface : IWorldPassSurface
+ {
+ public void PrepareClipFrame() { }
+
+ public void EnableClipDistances() { }
+
+ public void DisableClipDistances() { }
+
+ public void ClearInteriorDepth() =>
+ throw new InvalidOperationException("An outdoor root cannot clear interior depth.");
+ }
+
+ private sealed class FixedWorldPassScope : IWorldPassScope
+ {
+ private IGpuPassEncoder? _encoder;
+
+ public int SampleCount => 1;
+
+ public IGpuPassEncoder? CurrentEncoder => _encoder;
+
+ public int AttachmentWidth => 1024;
+
+ public int AttachmentHeight => 720;
+
+ public WorldFrameSections Sections { get; } = new();
+
+ public IGpuPassEncoder RequireEncoder() =>
+ _encoder ?? throw new InvalidOperationException("No recording world pass is open.");
+
+ public void ClearInteriorDepth() =>
+ throw new InvalidOperationException("An outdoor root cannot clear interior depth.");
+
+ public IDisposable Publish(IGpuPassEncoder encoder)
+ {
+ Assert.Null(_encoder);
+ _encoder = encoder;
+ Sections.Reset();
+ return new Publication(this);
+ }
+
+ private sealed class Publication(FixedWorldPassScope owner) : IDisposable
+ {
+ public void Dispose()
+ {
+ owner._encoder = null;
+ owner.Sections.Reset();
+ }
+ }
+ }
+
+ private sealed class DefaultGlStateReader : IRenderGlStateReader
+ {
+ public RenderGlStateSnapshot CaptureState() => default;
+
+ public RenderGlScissorSnapshot CaptureScissor() => default;
+ }
+
+ private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog
+ {
+ public void WriteLine(string message) { }
+ }
+
+ private sealed class EmptyFrameFacts : IFramePipelineDiagnosticFactsSource
+ {
+ public TerrainRenderDiagnosticFacts CaptureTerrain() => default;
+
+ public FramePipelineDiagnosticFacts CaptureFrame() => default;
+ }
+
private sealed class FoundationSource(RenderFrameFoundation foundation) :
IRenderFrameFoundationSource
{
@@ -909,6 +1190,137 @@ public sealed class WorldSceneRendererTests
}
}
+ private sealed class NullPreparedAssetSource : IPreparedAssetSource
+ {
+ public PreparedAssetSourceStats Stats => default;
+
+ public CacheStats DecodedTextureCacheStats => default;
+
+ public PreparedAssetPresence Probe(
+ AcDream.Content.Pak.PakAssetType type,
+ uint sourceFileId) =>
+ PreparedAssetPresence.Missing;
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default) =>
+ PreparedAssetReadResult.Missing;
+
+ public void Dispose() { }
+ }
+
+ private sealed class NoopDatReaderWriter : IDatReaderWriter
+ {
+ private readonly StubDatabase _portal = new();
+ private readonly StubDatabase _highRes = new();
+ private readonly StubDatabase _language = new();
+ private readonly StubDatabase _cell = new();
+
+ public string SourceDirectory => string.Empty;
+
+ public IDatDatabase Portal => _portal;
+
+ public IDatDatabase Cell => _cell;
+
+ public ReadOnlyDictionary CellRegions { get; } =
+ new(new Dictionary());
+
+ public IDatDatabase HighRes => _highRes;
+
+ public IDatDatabase Language => _language;
+
+ public IDatDatabase Local => _language;
+
+ public ReadOnlyDictionary RegionFileMap { get; } =
+ new(new Dictionary());
+
+ public int PortalIteration => 0;
+
+ public int CellIteration => 0;
+
+ public int HighResIteration => 0;
+
+ public int LanguageIteration => 0;
+
+ public bool TryGetFileBytes(
+ uint regionId,
+ uint fileId,
+ ref byte[] bytes,
+ out int bytesRead)
+ {
+ bytesRead = 0;
+ return false;
+ }
+
+ public IEnumerable GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public IEnumerable ResolveId(uint id) =>
+ Array.Empty();
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public bool TrySave(
+ uint regionId,
+ T obj,
+ int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ [return: MaybeNull]
+ public T Get(uint fileId) where T : IDBObj => default;
+
+ public bool TryGet(
+ uint fileId,
+ [MaybeNullWhen(false)] out T value) where T : IDBObj
+ {
+ value = default;
+ return false;
+ }
+
+ public void Dispose() { }
+
+ private sealed class StubDatabase : IDatDatabase
+ {
+ public DatDatabase Db => throw new NotSupportedException();
+
+ public int Iteration => 0;
+
+ public IEnumerable GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public bool TryGet(
+ uint fileId,
+ [MaybeNullWhen(false)] out T value) where T : IDBObj
+ {
+ value = default;
+ return false;
+ }
+
+ public bool TryGetFileBytes(
+ uint fileId,
+ [MaybeNullWhen(false)] out byte[] value)
+ {
+ value = null;
+ return false;
+ }
+
+ public bool TryGetFileBytes(
+ uint fileId,
+ ref byte[] bytes,
+ out int bytesRead)
+ {
+ bytesRead = 0;
+ return false;
+ }
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public void Dispose() { }
+ }
+ }
+
private static WorldRenderFrame CreateFrame(
LoadedCell? clipRoot,
bool playerSeenOutside)