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)