fix(rendering): repair EnvCell retail CLIP state

Exclude deferred EnvCell subsets from the opaque turn, preserve exact CLIP/ALPHA fixed-state groups through leaf replay, and use retail's row-3 override, blend, depth, and texture-class alpha references. Keep the existing building-detail sentinel distinct from the two CLIP references.

Bound rejected source payload, replace no-op allocation proofs with actual EnvCell and particle RHI paths, rebuild checked-in SPIR-V, and correct AP-238/AP-240 plus the S4-c2 evidence record.

Gates: Release 0W/0E; hermetic 16735/0/0; InstalledDat 255 pass/10 known fail/1 skip; shaders 32/32; focused 239/239; allocation 2/2 at 0 B. The evidence/comment repair re-ran Release 0W/0E, shaders 32/32, affected 37/37, and allocation 2/2; mesh_detail.vert.spv remained byte-identical at SHA-256 5346247ab7d606046943e19b28888c814e08dc6cb27cd9750096ac055457eb57.

Mutation proof, with each mutation restored after its named first failure:

1. Restoring the opaque predicate to !IsAdditive fails WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain first at draw count: expected 1, actual 3.
2. Selecting _alphaPipeline for CLIP fails WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference first at the bind sequence: expected [envcell-clip], actual [envcell-alpha].
3. Disabling CLIP depth write fails that production Theory first at Assert.True(clipPipeline.Depth.Write): expected true, actual false.
4. Swapping palette/DDS references fails the DDS row first: expected 0.784313738, actual 0.392156869; the palette row reports the inverse.
5. Mutating mesh_modern.frag from < to <= fails ClipShaders_UseGreaterEqualForThePerRangeReference first at Assert.Contains("if (color.a < alphaCutoff) discard;"): the required source spelling is absent.
6. Restoring row-3 OverrideClipmap=true fails the real-interface clip Theory first at Assert.False: expected false, actual true.
7. Deleting failed-append rollback fails the flush/end/abort rejection-storm rows first at the bounded pending-count assertion: expected 0, actual 9000.
8. Selecting _transparentDetailPipeline for a ClipMap detail contribution fails the leaf detail pin first at the bind sequence: expected second bind envcell-retail-detail-clip, actual envcell-retail-detail-alpha.
9. Resetting detail ParamB to zero fails the same detail pin first at the second pushed reference: expected 0.784313738, actual 0.
10. Classifying CLIP with exact mask equality excludes legal 0x09 and fails WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference first at pipeline: expected envcell-clip, actual envcell-alpha.
11. Mapping the new blend to SRC_ALPHA/INVSRCALPHA fails AllRetailBlendModesAreRepresentable first at the tuple: expected (One, OneMinusSrcAlpha), actual (SrcAlpha, OneMinusSrcAlpha).
12. Restoring mesh_detail.vert's uParamB > 0.5 category predicate fails ClipShaders_UseGreaterEqualForThePerRangeReference first because vDetailCategory = uParamB == 1.0 is absent.
13. Treating every positive detail uParamB as a cutoff fails that source pin first because isRetailClipReference(uParamB) ? uParamB : 0.05 is absent.
14. Adding arbitrary 0.5 as an accepted reference to either mesh_modern.frag or mesh_detail.frag fails that source pin first at Assert.DoesNotContain("value - 0.5"); both mutations were run and reversed independently.

Retail: D3DPolyRender::SetSurface @ 0x0059c4d0; paired binary @ 0x0059c72a, 0x0059c747/0x0059c74f, 0x0059c821, 0x0059c838, 0x0059c866.
This commit is contained in:
Erik 2026-09-04 10:36:12 +02:00
parent 0aa166aa09
commit 252886e84f
21 changed files with 812 additions and 277 deletions

View file

@ -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-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-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-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 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-238 | **S4-c2 (2026-09-04); NARROWED FURTHER by fix rounds 1/2 and the owner-authorized repair — renumbered from the colliding id "AP-236" (that id was already filed AND retired on `main`; ids are never reused).** `RetailAlphaQueue` is retail's independent CLIP/ALPHA FIFO pair. The repaired production leaf excludes every non-additive transparent EnvCell subset from its preceding opaque draw, retains each real batch's `RetailSurfaceMask`, and routes canonical `0x08` plus legal positive-stipple `0x09` to CLIP with row-3 `overrideClipmap=false`; `0x02` routes to ALPHA. EnvCell CLIP now uses `SetSurface`'s exact `ONE/INVSRCALPHA`, depth-test/write-on state and `GREATER_EQUAL` reference (100/255 paletted, 200/255 DDS); detail-active row 1 keeps that CLIP base/reference before its detail contribution. A mixed cell can contribute to both lists without cross-feeding. The only remaining residual is 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` (`BatchBelongsToPass`, retained-mask state grouping, 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/state 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; `D3DPolyRender::SetSurface` 0x0059C4D0 (paired binary: blend ids 2/6 @0x0059C72A, refs `0x64`/`0xC8` at globals 0x00820D8C/0x00820D90, func 7, depth-write byte 1 @0x0059C866); 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-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-240 | **Filed 2026-09-04 (S4-c2 fix round 1, M2); narrowed at fix round 2 and the owner-authorized repair.** `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. CLIP has two genuine production feeder families outside this residual: clip-mapped mesh particles and exact-mask EnvCell subsets (canonical `F4180104` / surface `08000BFF`), with EnvCell now proven through the real leaf and exact `SetSurface` CLIP state. The residual is only ordinary GfxObj/scenery/building content; no ordinary-Wb A2C behavior changed in this repair. | `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-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-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 | | 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 |

View file

@ -121,6 +121,14 @@ internal enum GpuBlendMode
/// <summary>Straight alpha: <c>SrcAlpha, OneMinusSrcAlpha</c>.</summary> /// <summary>Straight alpha: <c>SrcAlpha, OneMinusSrcAlpha</c>.</summary>
StraightAlpha, StraightAlpha,
/// <summary>
/// Premultiplied/source-over: <c>One, OneMinusSrcAlpha</c>. Retail pure
/// Base1ClipMap surfaces select exactly this pair in
/// <c>D3DPolyRender::SetSurface</c> @0x0059c72a when the per-entry
/// override-clipmap byte is clear.
/// </summary>
PremultipliedAlpha,
/// <summary>Additive: <c>SrcAlpha, One</c>.</summary> /// <summary>Additive: <c>SrcAlpha, One</c>.</summary>
Additive, Additive,

View file

@ -174,6 +174,7 @@ internal static class VulkanViewportMapping
internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch
{ {
GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),

View file

@ -131,7 +131,7 @@ public sealed unsafe partial class ParticleRenderer
/// <summary> /// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no /// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
/// <c>BindlessSupport</c>: the five pipelines compile <c>particle</c> and /// <c>BindlessSupport</c>: the six pipelines compile <c>particle</c> and
/// <c>particle_mesh</c> from the committed SPIR-V, and both texture sources /// <c>particle_mesh</c> from the committed SPIR-V, and both texture sources
/// already hand out the device's own <c>GpuTextureSlot</c> (V4t). /// already hand out the device's own <c>GpuTextureSlot</c> (V4t).
/// </summary> /// </summary>
@ -267,9 +267,11 @@ public sealed unsafe partial class ParticleRenderer
}); });
/// <summary> /// <summary>
/// One mesh-particle pipeline. Same depth bracket as the billboards (see /// One mesh-particle pipeline. Every variant uses retail's world
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the world /// <c>GL_LESS</c> compare; delayed alpha variants match the billboard
/// <c>GL_LESS</c> citation); the winding is CW because /// no-write bracket, while row 5's opaque variant depth-writes. See
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the compare
/// citation. The winding is CW because
/// <c>PrepareMeshPipeline</c> sets <c>glFrontFace(GL_CW)</c>, and the cull /// <c>PrepareMeshPipeline</c> sets <c>glFrontFace(GL_CW)</c>, and the cull
/// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's /// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
/// own <c>CullMode</c>. /// own <c>CullMode</c>.

View file

@ -263,13 +263,31 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
if ((routes & EnvCellTransparentRoute.Clip) != 0) if ((routes & EnvCellTransparentRoute.Clip) != 0)
{ {
int token = clipSource.AddPendingCellId(cellId); int token = clipSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true); // This fixed source is ordinary DrawMesh row 3: pure ClipMap,
// multipass false, therefore CLIP with overrideClipmap=false.
// If the queue drops at 3,000, retain no source-owned payload;
// TryAppend has already registered the source for exact reset.
if (!queue.TryAppend(
RetailAlphaList.Clip,
clipSource,
token,
overrideClipmap: false))
{
clipSource.RollbackPendingCellId(token);
}
} }
if ((routes & EnvCellTransparentRoute.Alpha) != 0) if ((routes & EnvCellTransparentRoute.Alpha) != 0)
{ {
int token = alphaSource.AddPendingCellId(cellId); int token = alphaSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false); if (!queue.TryAppend(
RetailAlphaList.Alpha,
alphaSource,
token,
overrideClipmap: false))
{
alphaSource.RollbackPendingCellId(token);
}
} }
} }
@ -297,13 +315,27 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
if ((routes & EnvCellTransparentRoute.Clip) != 0) if ((routes & EnvCellTransparentRoute.Clip) != 0)
{ {
int token = clipSource.AddPendingCellId(cellId); int token = clipSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true); if (!queue.TryAppend(
RetailAlphaList.Clip,
clipSource,
token,
overrideClipmap: false))
{
clipSource.RollbackPendingCellId(token);
}
} }
if ((routes & EnvCellTransparentRoute.Alpha) != 0) if ((routes & EnvCellTransparentRoute.Alpha) != 0)
{ {
int token = alphaSource.AddPendingCellId(cellId); int token = alphaSource.AddPendingCellId(cellId);
queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false); if (!queue.TryAppend(
RetailAlphaList.Alpha,
alphaSource,
token,
overrideClipmap: false))
{
alphaSource.RollbackPendingCellId(token);
}
} }
} }
@ -327,15 +359,26 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
EnvCellTransparentRoute route, EnvCellTransparentRoute route,
bool detailSurfaceActive); bool detailSurfaceActive);
internal sealed class EnvCellAlphaDrawSource( internal sealed class EnvCellAlphaDrawSource : IRetailAlphaDrawSource
RenderEnvCellsByRoute renderTransparentOrdered,
EnvCellTransparentRoute route)
: IRetailAlphaDrawSource
{ {
private readonly RenderEnvCellsByRoute _renderTransparentOrdered;
private readonly EnvCellTransparentRoute _route;
private readonly Action? _resetObserver;
private readonly List<uint> _pendingCellIds = new(); private readonly List<uint> _pendingCellIds = new();
private readonly List<uint> _preparedCellIds = new(); private readonly List<uint> _preparedCellIds = new();
private readonly List<uint> _drawScratch = new(); private readonly List<uint> _drawScratch = new();
internal EnvCellAlphaDrawSource(
RenderEnvCellsByRoute renderTransparentOrdered,
EnvCellTransparentRoute route,
Action? resetObserver = null)
{
_renderTransparentOrdered = renderTransparentOrdered
?? throw new ArgumentNullException(nameof(renderTransparentOrdered));
_route = route;
_resetObserver = resetObserver;
}
/// <summary>Returns this cell's token — its index into /// <summary>Returns this cell's token — its index into
/// <see cref="_pendingCellIds"/> at the moment it was added.</summary> /// <see cref="_pendingCellIds"/> at the moment it was added.</summary>
internal int AddPendingCellId(uint cellId) internal int AddPendingCellId(uint cellId)
@ -345,6 +388,22 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
return token; return token;
} }
/// <summary>
/// Rolls back only the just-reserved tail token after queue rejection.
/// Accepted indices therefore never move, while a rejection storm can
/// neither grow the pending count nor force a second geometric growth
/// beyond the 3,000-entry list's warmed capacity.
/// </summary>
internal void RollbackPendingCellId(int token)
{
if (token != _pendingCellIds.Count - 1)
{
throw new InvalidOperationException(
"Only the just-reserved EnvCell alpha token can be rolled back.");
}
_pendingCellIds.RemoveAt(token);
}
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens) public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{ {
_preparedCellIds.Clear(); _preparedCellIds.Clear();
@ -359,7 +418,10 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_drawScratch.Clear(); _drawScratch.Clear();
for (int i = 0; i < drawCount; i++) for (int i = 0; i < drawCount; i++)
_drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]); _drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]);
renderTransparentOrdered(_drawScratch, route, detailSurfaceActive: false); _renderTransparentOrdered(
_drawScratch,
_route,
detailSurfaceActive: false);
} }
public void ResetAlphaSubmissions() public void ResetAlphaSubmissions()
@ -367,9 +429,13 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_pendingCellIds.Clear(); _pendingCellIds.Clear();
_preparedCellIds.Clear(); _preparedCellIds.Clear();
_drawScratch.Clear(); _drawScratch.Clear();
_resetObserver?.Invoke();
} }
internal int PendingCount => _pendingCellIds.Count; internal int PendingCount => _pendingCellIds.Count;
internal int PendingCapacity => _pendingCellIds.Capacity;
internal int PreparedCapacity => _preparedCellIds.Capacity;
internal int DrawCapacity => _drawScratch.Capacity;
} }
/// <summary> /// <summary>

View file

@ -11,6 +11,10 @@ in flat uint vBatchFlags;
in flat uint vDetailCategory; in flat uint vDetailCategory;
uniform uint uTextureIndexA; // category detail texture, layer 0 uniform uint uTextureIndexA; // category detail texture, layer 0
// S4-c2 F3: 100/255 or 200/255 for a pure ClipMap base/detail pair. Exact 1
// remains mesh_detail.vert's existing building-category sentinel and must use
// the ordinary empty-fragment cutoff. Zero is the ordinary EnvCell category.
uniform float uParamB;
// SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same // SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same
// std140 block, same struct, same binding). Declared here ONLY for fog // std140 block, same struct, same binding). Declared here ONLY for fog
@ -50,6 +54,11 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
out vec4 FragColor; out vec4 FragColor;
bool isRetailClipReference(float value) {
return abs(value - (100.0 / 255.0)) < 0.000001
|| abs(value - (200.0 / 255.0)) < 0.000001;
}
// VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID // VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID
// 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports // 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports
// m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1, // m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1,
@ -100,7 +109,8 @@ void main() {
vec4 base = ACDREAM_SAMPLE_ARRAY( vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex, vBaseTextureIndex,
vec3(vBaseUv, float(vBaseTextureLayer))); vec3(vBaseUv, float(vBaseTextureLayer)));
if (base.a < 0.05) float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
if (base.a < alphaCutoff)
discard; discard;
vec4 detail = ACDREAM_SAMPLE_ARRAY( vec4 detail = ACDREAM_SAMPLE_ARRAY(

View file

@ -38,9 +38,9 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[]; uint instanceClipSlot[];
}; };
// Object renderer only: 1 for a retail building shell, 0 for ordinary // Exact uParamB=1 selects this per-instance building/object category. EnvCell
// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the // zero and its pure-ClipMap 100/255 or 200/255 references retain the bound
// value, but still binds one valid word because Vulkan sees this static use. // environment category; the fragment shader consumes those CLIP references.
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[]; uint instanceDetailCategory[];
}; };
@ -61,7 +61,10 @@ uniform mat4 uViewProjection;
uniform int uDrawIDOffset; uniform int uDrawIDOffset;
uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
uniform float uParamA; // detail UV tiling uniform float uParamA; // detail UV tiling
uniform float uParamB; // 1 = require building instance, 0 = EnvCell category // Exact 1 remains the existing "require building instance" sentinel. S4-c2
// also passes 100/255 or 200/255 for an EnvCell ClipMap alpha reference; both
// must retain the EnvCell category rather than taking the building branch.
uniform float uParamB;
out vec2 vBaseUv; out vec2 vBaseUv;
out vec2 vDetailUv; out vec2 vDetailUv;
@ -95,7 +98,7 @@ void main() {
vBaseTextureIndex = batch.textureIndex; vBaseTextureIndex = batch.textureIndex;
vBaseTextureLayer = batch.textureLayer; vBaseTextureLayer = batch.textureLayer;
vBatchFlags = batch.flags; vBatchFlags = batch.flags;
vDetailCategory = uParamB > 0.5 vDetailCategory = uParamB == 1.0
? instanceDetailCategory[instanceIndex] ? instanceDetailCategory[instanceIndex]
: 1u; : 1u;
} }

View file

@ -22,6 +22,10 @@ in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
// transparent overdraw cost otherwise) // transparent overdraw cost otherwise)
uniform int uRenderPass; uniform int uRenderPass;
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
// S4-c2 F3: only retail's exact pure-ClipMap alpha-test references are
// recognized, supplied per MDI range from TextureKey.PaletteId (100/255
// paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05.
uniform float uParamB;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light { struct Light {
@ -57,15 +61,23 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
out vec4 FragColor; out vec4 FragColor;
bool isRetailClipReference(float value) {
return abs(value - (100.0 / 255.0)) < 0.000001
|| abs(value - (200.0 / 255.0)) < 0.000001;
}
void main() { void main() {
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
// Two-pass alpha-test (N.5 Decision 2). // Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth. // derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) { if (uRenderPass == 0) {
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) // '<' spells D3DCMP_GREATEREQUAL: equality passes. Retail loads
// 100 or 200 and func 7 at SetSurface 0x0059c73c..0x0059c838.
if (color.a < alphaCutoff) discard;
} else { } else {
// Transparent pass. // Transparent pass.
// //
@ -84,7 +96,7 @@ void main() {
// Keep the α<0.05 short-circuit as a fragment-cost optimization // Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins). // sprites with large transparent margins).
if (color.a < 0.05) discard; if (color.a < alphaCutoff) discard;
} }
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped // Per-vertex Gouraud lighting from the vertex shader (ambient + capped

View file

@ -231,12 +231,12 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "0c1eddb0fc892cb76ef4da47f727cec2516052d63247fa2682918bfd60b84fa8", "sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181",
"compiled": true "compiled": true
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1", "sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335",
"compiled": true "compiled": true
} }
] ]
@ -252,7 +252,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "12f2ceec9a8420bd273650e95251ca56c3bf3c3691fce1a539e4c5aea7abaabf", "sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e",
"compiled": true "compiled": true
} }
] ]

View file

@ -26,9 +26,11 @@ public sealed unsafe partial class EnvCellRenderer
private readonly IWorldPassScope? _scope; private readonly IWorldPassScope? _scope;
private IGpuPipeline? _opaquePipeline; private IGpuPipeline? _opaquePipeline;
private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _clipPipeline;
private IGpuPipeline? _additivePipeline; private IGpuPipeline? _additivePipeline;
private IGpuPipeline? _detailPipeline; private IGpuPipeline? _detailPipeline;
private IGpuPipeline? _transparentDetailPipeline; private IGpuPipeline? _transparentDetailPipeline;
private IGpuPipeline? _clipDetailPipeline;
private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
private readonly Func<bool> _buildingDetailEnabled; private readonly Func<bool> _buildingDetailEnabled;
@ -48,7 +50,7 @@ public sealed unsafe partial class EnvCellRenderer
/// <summary> /// <summary>
/// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the /// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the
/// five pipelines ARE this renderer's program, so there is no second step /// seven pipelines ARE this renderer's program, so there is no second step
/// and no <c>Shader</c> to hand in. /// and no <c>Shader</c> to hand in.
/// </summary> /// </summary>
internal EnvCellRenderer( internal EnvCellRenderer(
@ -72,6 +74,12 @@ public sealed unsafe partial class EnvCellRenderer
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount); device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
_alphaPipeline = CreateShellPipeline( _alphaPipeline = CreateShellPipeline(
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount); device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount);
_clipPipeline = CreateShellPipeline(
device,
"envcell-clip",
GpuBlendMode.PremultipliedAlpha,
depthWrite: true,
scope.SampleCount);
_additivePipeline = CreateShellPipeline( _additivePipeline = CreateShellPipeline(
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
_detailPipeline = CreateShellPipeline( _detailPipeline = CreateShellPipeline(
@ -92,6 +100,15 @@ public sealed unsafe partial class EnvCellRenderer
shaderName: "mesh_detail", shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare( depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: true)); transparent: true));
_clipDetailPipeline = CreateShellPipeline(
device,
"envcell-retail-detail-clip",
GpuBlendMode.RetailDetail,
depthWrite: true,
scope.SampleCount,
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
transparent: false));
_initialized = true; _initialized = true;
} }
@ -274,12 +291,18 @@ public sealed unsafe partial class EnvCellRenderer
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex; int groupIndex = drawRange.GroupIndex;
CullMode cullMode = ResolveRetailCellShellCullMode( CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(groupIndex % 4)); (CullMode)(groupIndex % CullGroupCount));
bool isAdditive = groupIndex >= 4; bool isAdditive = renderPass == WbRenderPass.Transparent
IGpuPipeline rangeBasePipeline = isAdditive && groupIndex >= AdditiveGroupBase
? _additivePipeline! && groupIndex < ClipDdsGroupBase;
: _alphaPipeline!; bool isClip = renderPass == WbRenderPass.Transparent
&& groupIndex >= ClipDdsGroupBase;
IGpuPipeline rangeBasePipeline = isClip
? _clipPipeline!
: isAdditive
? _additivePipeline!
: _alphaPipeline!;
if (renderPass == WbRenderPass.Transparent) if (renderPass == WbRenderPass.Transparent)
{ {
// Blend state is the pipeline's; switching variants mid-pass has // Blend state is the pipeline's; switching variants mid-pass has
@ -298,6 +321,9 @@ public sealed unsafe partial class EnvCellRenderer
? (int)renderPass | 0x100 ? (int)renderPass | 0x100
: (int)renderPass; : (int)renderPass;
pushConstants.DrawIdOffset = drawRange.FirstCommand; pushConstants.DrawIdOffset = drawRange.FirstCommand;
pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
// Retail DrawMesh's two-pass fallback redraws each transparent // Retail DrawMesh's two-pass fallback redraws each transparent
@ -317,7 +343,9 @@ public sealed unsafe partial class EnvCellRenderer
pushConstants.DrawIdOffset = command; pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = 0; pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f; pushConstants.ParamA = 0f;
pushConstants.ParamB = 0f; pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect( encoder.MultiDrawIndexedIndirect(
commandBuffer, commandBuffer,
@ -325,12 +353,17 @@ public sealed unsafe partial class EnvCellRenderer
1, 1,
(uint)sizeof(DrawElementsIndirectCommand)); (uint)sizeof(DrawElementsIndirectCommand));
BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh); BindPipelineWithMesh(
encoder,
isClip ? _clipDetailPipeline! : _transparentDetailPipeline!,
mesh);
SetCullMode(encoder, cullMode); SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = command; pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling; pushConstants.ParamA = _environmentDetail.Tiling;
pushConstants.ParamB = 0f; pushConstants.ParamB = isClip
? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
: 0f;
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect( encoder.MultiDrawIndexedIndirect(
commandBuffer, commandBuffer,
@ -348,9 +381,11 @@ public sealed unsafe partial class EnvCellRenderer
(uint)sizeof(DrawElementsIndirectCommand)); (uint)sizeof(DrawElementsIndirectCommand));
} }
// Retail DrawEnvCell category (2). Replay the already-filtered opaque // Retail DrawEnvCell category (2). Replay only the already-filtered
// shell commands, including ClipMap built-mesh subsets. No distance // opaque shell commands. Transparent ClipMap is excluded here: it
// fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the // takes the immediate/delayed CLIP path and, when detail is active,
// its own _clipDetailPipeline contribution. No distance fade
// (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
// immediate-polygon path only, which built meshes never reach; // immediate-polygon path only, which built meshes never reach;
// attenuation is the sampler's linear mip chain converging to the // attenuation is the sampler's linear mip chain converging to the
// texture mean. The existing "Building Detail Textures" option gates // texture mean. The existing "Building Detail Textures" option gates
@ -368,7 +403,7 @@ public sealed unsafe partial class EnvCellRenderer
{ {
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
CullMode cullMode = ResolveRetailCellShellCullMode( CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % 4)); (CullMode)(drawRange.GroupIndex % CullGroupCount));
SetCullMode(encoder, cullMode); SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand; pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
@ -468,9 +503,10 @@ public sealed unsafe partial class EnvCellRenderer
/// <summary> /// <summary>
/// Binds one category word for <c>mesh_detail.vert</c>'s statically used /// Binds one category word for <c>mesh_detail.vert</c>'s statically used
/// binding 9. EnvCell draws select their renderer-wide category via /// binding 9. The vertex shader treats only exact <c>uParamB=1</c> as the
/// <c>uParamB=0</c>, so the value is semantically unused, but Vulkan still /// object/building-category sentinel; ordinary EnvCell zero and the CLIP
/// requires the declared descriptor to be valid. /// references 100/255 and 200/255 therefore keep the environment category.
/// Vulkan still requires the declared descriptor to be valid.
/// </summary> /// </summary>
internal static void BindEnvironmentDetailCategory( internal static void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder, IGpuPassEncoder encoder,
@ -491,11 +527,15 @@ public sealed unsafe partial class EnvCellRenderer
_opaquePipeline = null; _opaquePipeline = null;
_alphaPipeline?.Dispose(); _alphaPipeline?.Dispose();
_alphaPipeline = null; _alphaPipeline = null;
_clipPipeline?.Dispose();
_clipPipeline = null;
_additivePipeline?.Dispose(); _additivePipeline?.Dispose();
_additivePipeline = null; _additivePipeline = null;
_detailPipeline?.Dispose(); _detailPipeline?.Dispose();
_detailPipeline = null; _detailPipeline = null;
_transparentDetailPipeline?.Dispose(); _transparentDetailPipeline?.Dispose();
_transparentDetailPipeline = null; _transparentDetailPipeline = null;
_clipDetailPipeline?.Dispose();
_clipDetailPipeline = null;
} }
} }

View file

@ -142,8 +142,17 @@ public sealed partial class EnvCellRenderer :
private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new();
private readonly Dictionary<ulong, List<InstanceData>> _filteredGroups = new(); private readonly Dictionary<ulong, List<InstanceData>> _filteredGroups = new();
private readonly HashSet<List<InstanceData>> _filteredOwnedLists = new(); private readonly HashSet<List<InstanceData>> _filteredOwnedLists = new();
// S4-c2 F3: four cull modes for each fixed-state family. The original
// eight slots were non-additive/additive only; transparent replay also
// needs distinct pure-ClipMap DDS/paletted ranges because their alpha-test
// references differ and neither state may share an MDI call with ALPHA.
private const int CullGroupCount = 4;
private const int AdditiveGroupBase = 4;
private const int ClipDdsGroupBase = 8;
private const int ClipPalettedGroupBase = 12;
private const int BatchGroupCount = 16;
private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup = private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup =
Enumerable.Range(0, 8) Enumerable.Range(0, BatchGroupCount)
.Select(_ => new List<(ObjectRenderBatch, int, int)>()) .Select(_ => new List<(ObjectRenderBatch, int, int)>())
.ToArray(); .ToArray();
private readonly List<int> _activeCullGroups = new(8); private readonly List<int> _activeCullGroups = new(8);
@ -1214,17 +1223,8 @@ public sealed partial class EnvCellRenderer :
foreach (var batch in call.renderData.Batches) foreach (var batch in call.renderData.Batches)
{ {
// WB BaseObjectRenderManager.cs:723-731: pass-filter. // WB BaseObjectRenderManager.cs:723-731: pass-filter.
if (renderPass != WbRenderPass.SinglePass) if (!BatchBelongsToPass(batch, renderPass))
{ continue;
if (batch.IsAdditive)
{
if (renderPass == WbRenderPass.Opaque) continue;
}
else if (!batch.IsTransparent)
{
if (renderPass == WbRenderPass.Transparent) continue;
}
}
if (renderPass == WbRenderPass.Transparent if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All && transparentRoute != EnvCellTransparentRoute.All
@ -1277,17 +1277,8 @@ public sealed partial class EnvCellRenderer :
var call = drawCalls[callIndex]; var call = drawCalls[callIndex];
foreach (var batch in call.renderData.Batches) foreach (var batch in call.renderData.Batches)
{ {
if (renderPass != WbRenderPass.SinglePass) if (!BatchBelongsToPass(batch, renderPass))
{ continue;
if (batch.IsAdditive)
{
if (renderPass == WbRenderPass.Opaque) continue;
}
else if (!batch.IsTransparent)
{
if (renderPass == WbRenderPass.Transparent) continue;
}
}
if (renderPass == WbRenderPass.Transparent if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All && transparentRoute != EnvCellTransparentRoute.All
@ -1296,7 +1287,9 @@ public sealed partial class EnvCellRenderer :
continue; continue;
} }
int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0); int groupIndex = ResolveBatchGroupIndex(
batch,
renderPass);
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex]; _batchesByCullGroup[groupIndex];
if (group.Count == 0) if (group.Count == 0)
@ -1357,6 +1350,50 @@ public sealed partial class EnvCellRenderer :
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount); SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
} }
/// <summary>
/// Exact WB pass membership, with <see cref="WbRenderPass.SinglePass"/>
/// intentionally unchanged. A non-additive transparent batch is delayed
/// content and must never draw during the preceding opaque cell turn.
/// </summary>
internal static bool BatchBelongsToPass(
ObjectRenderBatch batch,
WbRenderPass renderPass) => renderPass switch
{
WbRenderPass.Opaque => !batch.IsAdditive && !batch.IsTransparent,
WbRenderPass.Transparent => batch.IsAdditive || batch.IsTransparent,
WbRenderPass.SinglePass => true,
_ => false,
};
private static int ResolveBatchGroupIndex(
ObjectRenderBatch batch,
WbRenderPass renderPass)
{
int cull = (int)batch.CullMode;
if ((uint)cull >= CullGroupCount)
throw new ArgumentOutOfRangeException(nameof(batch), batch.CullMode, "Unknown cell-shell cull mode.");
// Preserve the old SinglePass grouping exactly: non-additive 0..3,
// additive 4..7. Its submission path still keeps the opaque pipeline.
if (renderPass != WbRenderPass.Transparent)
return cull + (batch.IsAdditive ? AdditiveGroupBase : 0);
if (batch.IsAdditive)
return cull + AdditiveGroupBase;
// Fixed state follows ConstructMesh's retained per-subset class, not
// the DrawMesh row action. Detail-active row 1 is Immediate, but a
// mask-0x08 subset (including legal positive-stipple 0x09) still needs
// the CLIP base pipeline/reference. ConstructMesh's priority already
// excludes additive/alpha-family rows before retaining this class.
if ((batch.RetailSurfaceMask & RetailAlphaMeshRouter.MaskClipMap) == 0)
return cull;
return cull + (batch.Key.PaletteId != 0
? ClipPalettedGroupBase
: ClipDdsGroupBase);
}
internal static void AppendMdiDrawRange( internal static void AppendMdiDrawRange(
List<MdiDrawRange> ranges, List<MdiDrawRange> ranges,
int groupIndex, int groupIndex,

View file

@ -1,4 +1,6 @@
using System.Numerics; using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Gpu.Vk;
@ -38,44 +40,38 @@ public sealed class EnvCellAlphaDrawSourceTests
} }
/// <summary> /// <summary>
/// A mixed cell creates one token in each list and neither set draws before /// The real walk leaf executes the preceding opaque pass, dispatch, and
/// the real queue drain. CLIP drains first and receives only the CLIP /// queue drain. The opaque subset draws at the turn; CLIP and ALPHA do not.
/// replay filter; ALPHA receives only ALPHA. Mutation checks: inverting the /// Each delayed list then replays exactly its own subset.</summary>
/// detail-off immediate predicate makes <c>Assert.Empty(log)</c> fail;
/// removing the replay filter makes either route assertion fail.</summary>
[Fact] [Fact]
public void MixedCell_DefersOneTokenPerList_AndReplayIsListFiltered() public void WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain()
{ {
using var fixture = new ProductionEnvCellFixture( using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: false, detailSurfaceActive: false,
0x08, BatchSpec.Opaque,
0x02); BatchSpec.ClipDds,
EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes( BatchSpec.Alpha);
ProductionEnvCellFixture.CellId,
detailSurfaceActive: false);
fixture.Queue.BeginFrame(); fixture.Queue.BeginFrame();
RetailPViewPassExecutor.DispatchTransparentCellShell( fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
ProductionEnvCellFixture.CellId,
routes,
detailSurfaceActive: false,
fixture.Queue,
fixture.ClipSource,
fixture.AlphaSource,
fixture.ImmediateSink);
Assert.Equal(EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Alpha, routes); GpuRecordedMultiDrawIndirect turnDraw = Assert.Single(
Assert.Empty(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()); fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Equal(1u, turnDraw.DrawCount);
Assert.Equal(["envcell-opaque"], DrawPipelineNames(fixture.Device));
Assert.Equal(1, fixture.Queue.ClipCount); Assert.Equal(1, fixture.Queue.ClipCount);
Assert.Equal(1, fixture.Queue.AlphaCount); Assert.Equal(1, fixture.Queue.AlphaCount);
Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap);
fixture.Queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f); fixture.Queue.EndFrame();
GpuRecordedMultiDrawIndirect[] draws = GpuRecordedMultiDrawIndirect[] draws =
[.. fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()]; [.. fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
Assert.Equal(2, draws.Length); Assert.Equal(3, draws.Length);
Assert.All(draws, static draw => Assert.Equal(1u, draw.DrawCount)); Assert.All(draws, static draw => Assert.Equal(1u, draw.DrawCount));
fixture.Queue.EndFrame(); Assert.Equal(
["envcell-opaque", "envcell-clip", "envcell-alpha"],
DrawPipelineNames(fixture.Device));
} }
[Fact] [Fact]
@ -83,28 +79,29 @@ public sealed class EnvCellAlphaDrawSourceTests
{ {
using var fixture = new ProductionEnvCellFixture( using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: true, detailSurfaceActive: true,
0x08); BatchSpec.ClipDds);
EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
ProductionEnvCellFixture.CellId,
detailSurfaceActive: true);
fixture.Queue.BeginFrame(); fixture.Queue.BeginFrame();
RetailPViewPassExecutor.DispatchTransparentCellShell( fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
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<GpuRecordedMultiDrawIndirect>().Count()); Assert.Equal(2, fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count());
Assert.Equal(
["envcell-clip", "envcell-retail-detail-clip"],
DrawPipelineNames(fixture.Device));
Assert.All(
DrawPushConstants(fixture.Device),
constants => Assert.Equal(200f / 255f, constants.ParamB));
GpuPipelineDescription detailPipeline = fixture.Device.CreatedPipelines
.Single(static pipeline =>
pipeline.Description.Name == "envcell-retail-detail-clip")
.Description;
Assert.Equal(GpuBlendMode.RetailDetail, detailPipeline.Blend);
Assert.True(detailPipeline.Depth.Test);
Assert.True(detailPipeline.Depth.Write);
Assert.Equal(GpuCompareOp.Equal, detailPipeline.Depth.Compare);
Assert.Equal(0, fixture.Queue.PendingCount); Assert.Equal(0, fixture.Queue.PendingCount);
fixture.Queue.EndFrame(); fixture.Queue.EndFrame();
Assert.Equal(1, fixture.ImmediateSink.DrawCount); Assert.Equal(2, fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().Count());
} }
/// <summary>Two cell tokens from the SAME <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>, /// <summary>Two cell tokens from the SAME <see cref="RetailPViewPassExecutor.EnvCellAlphaDrawSource"/>,
@ -141,47 +138,202 @@ public sealed class EnvCellAlphaDrawSourceTests
} }
[Fact] [Fact]
public void ProductionDispatch_WarmedImmediateAndFilteredSourcesDoNotAllocate() public void ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate()
{ {
var sink = new NoAllocRouteSink(); using var fixture = new ProductionEnvCellFixture(
var clipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( detailSurfaceActive: false,
sink.Render, BatchSpec.Opaque,
EnvCellTransparentRoute.Clip); BatchSpec.ClipDds,
var alphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( BatchSpec.Alpha,
sink.Render, ringCapacityBytes: 64 * 1024 * 1024);
EnvCellTransparentRoute.Alpha);
RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate = sink.DrawImmediate;
var queue = new RetailAlphaQueue();
for (int i = 0; i < 64; i++) fixture.Device.Clear();
RunMixed(queue, clipSource, alphaSource, immediate); fixture.Device.RecordingEnabled = false;
long allocated = ZeroAllocationProbe.MeasureWarmed(
long before = GC.GetAllocatedBytesForCurrentThread(); fixture.RunWholeLeafFrame,
for (int i = 0; i < 1_000; i++) batchSize: 256,
RunMixed(queue, clipSource, alphaSource, immediate); warmupBatches: 2,
long allocated = GC.GetAllocatedBytesForCurrentThread() - before; samples: 4);
Assert.Equal(0, allocated); Assert.Equal(0, allocated);
} }
private static void RunMixed( [Theory]
RetailAlphaQueue queue, [InlineData(false, 200f / 255f)]
RetailPViewPassExecutor.EnvCellAlphaDrawSource clipSource, [InlineData(true, 100f / 255f)]
RetailPViewPassExecutor.EnvCellAlphaDrawSource alphaSource, public void WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference(
RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate) bool paletted,
float expectedReference)
{ {
queue.BeginFrame(); BatchSpec clip = paletted ? BatchSpec.ClipPaletted : BatchSpec.ClipDds;
RetailPViewPassExecutor.DispatchTransparentCellShell( using var fixture = new ProductionEnvCellFixture(
0xF4180104u,
EnvCellTransparentRoute.Immediate
| EnvCellTransparentRoute.Clip
| EnvCellTransparentRoute.Alpha,
detailSurfaceActive: false, detailSurfaceActive: false,
queue, clip);
clipSource,
alphaSource, fixture.Queue.BeginFrame();
immediate); fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
queue.EndFrame(); Assert.Empty(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap);
fixture.Queue.EndFrame();
Assert.Equal(["envcell-clip"], DrawPipelineNames(fixture.Device));
Assert.Equal(
expectedReference,
Assert.Single(DrawPushConstants(fixture.Device)).ParamB);
GpuPipelineDescription clipPipeline = fixture.Device.CreatedPipelines
.Single(static pipeline => pipeline.Description.Name == "envcell-clip")
.Description;
Assert.Equal(GpuBlendMode.PremultipliedAlpha, clipPipeline.Blend);
Assert.True(clipPipeline.Depth.Test);
Assert.True(clipPipeline.Depth.Write);
Assert.Equal(WorldDepthContract.WorldCompare, clipPipeline.Depth.Compare);
Assert.False(clipPipeline.AlphaToCoverage);
GpuPipelineDescription alphaPipeline = fixture.Device.CreatedPipelines
.Single(static pipeline => pipeline.Description.Name == "envcell-alpha")
.Description;
Assert.Equal(GpuBlendMode.StraightAlpha, alphaPipeline.Blend);
Assert.True(alphaPipeline.Depth.Test);
Assert.False(alphaPipeline.Depth.Write);
}
[Fact]
public void WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference()
{
using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: false,
BatchSpec.ClipPositiveStippleDds);
fixture.Queue.BeginFrame();
fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
Assert.Empty(fixture.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Equal(1, fixture.Queue.ClipCount);
Assert.Equal(0, fixture.Queue.AlphaCount);
Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap);
fixture.Queue.EndFrame();
Assert.Equal(["envcell-clip"], DrawPipelineNames(fixture.Device));
Assert.Equal(
200f / 255f,
Assert.Single(DrawPushConstants(fixture.Device)).ParamB);
}
/// <summary>
/// Vulkan has no fixed-function alpha test. The shader's discard-on-less
/// spelling is exactly GREATER_EQUAL: equality survives. Changing either
/// comparison to &lt;= (strict GREATER) fails this named source+manifest pin.
/// </summary>
[Fact]
public void ClipShaders_UseGreaterEqualForThePerRangeReference()
{
string root = RepositoryRoot();
string modern = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag"));
string detail = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.frag"));
string detailVertex = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.vert"));
Assert.Contains("if (color.a < alphaCutoff) discard;", modern, StringComparison.Ordinal);
Assert.DoesNotContain("if (color.a <= alphaCutoff) discard;", modern, StringComparison.Ordinal);
Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", modern, StringComparison.Ordinal);
Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", detail, StringComparison.Ordinal);
foreach (string shader in new[] { modern, detail })
{
Assert.Contains("abs(value - (100.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal);
Assert.Contains("abs(value - (200.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal);
Assert.DoesNotContain("uParamB > 0.0", shader, StringComparison.Ordinal);
Assert.DoesNotContain("value - 0.5", shader, StringComparison.Ordinal);
}
Assert.Contains("if (base.a < alphaCutoff)", detail, StringComparison.Ordinal);
Assert.DoesNotContain("if (base.a <= alphaCutoff)", detail, StringComparison.Ordinal);
Assert.Contains(
"vDetailCategory = uParamB == 1.0",
detailVertex,
StringComparison.Ordinal);
}
[Theory]
[InlineData(0)] // Flush
[InlineData(1)] // EndFrame
[InlineData(2)] // AbortFrame
public void RejectedEnvCellStorm_RollsBackPayloadAndStillResetsFirstUseSource(int completion)
{
const int retainedGeometricBound = 4096;
int rejectedResetCount = 0;
int rejectedDrawCount = 0;
var accepted = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
static (_, _, _) => { },
EnvCellTransparentRoute.Clip);
var rejected = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
(_, _, _) => rejectedDrawCount++,
EnvCellTransparentRoute.Clip,
() => rejectedResetCount++);
var unusedAlpha = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
static (_, _, _) => { },
EnvCellTransparentRoute.Alpha);
var queue = new RetailAlphaQueue();
RetailPViewPassExecutor.RenderImmediateEnvCellRoute neverImmediate =
static (_, _, _) => throw new InvalidOperationException();
queue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
{
RetailPViewPassExecutor.DispatchTransparentCellShell(
(uint)i,
EnvCellTransparentRoute.Clip,
detailSurfaceActive: false,
queue,
accepted,
unusedAlpha,
neverImmediate);
}
for (int i = 0; i < RetailAlphaQueue.ListCapacity * 3; i++)
{
RetailPViewPassExecutor.DispatchTransparentCellShell(
0xF4180104u,
EnvCellTransparentRoute.Clip,
detailSurfaceActive: false,
queue,
rejected,
unusedAlpha,
neverImmediate);
}
Assert.Equal(RetailAlphaQueue.ListCapacity, queue.ClipCount);
Assert.Equal(RetailAlphaQueue.ListCapacity, accepted.PendingCount);
Assert.Equal(0, rejected.PendingCount);
Assert.InRange(accepted.PendingCapacity, RetailAlphaQueue.ListCapacity, retainedGeometricBound);
Assert.InRange(rejected.PendingCapacity, 0, retainedGeometricBound);
switch (completion)
{
case 0:
queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
queue.AbortFrame();
break;
case 1:
queue.EndFrame();
break;
case 2:
queue.AbortFrame();
break;
default:
throw new ArgumentOutOfRangeException(nameof(completion));
}
Assert.Equal(1, rejectedResetCount);
Assert.Equal(0, rejectedDrawCount);
Assert.Equal(0, rejected.PendingCount);
Assert.InRange(accepted.PendingCapacity, 0, retainedGeometricBound);
Assert.InRange(accepted.PreparedCapacity, 0, retainedGeometricBound);
Assert.InRange(accepted.DrawCapacity, 0, retainedGeometricBound);
Assert.InRange(rejected.PendingCapacity, 0, retainedGeometricBound);
Assert.InRange(rejected.PreparedCapacity, 0, retainedGeometricBound);
Assert.InRange(rejected.DrawCapacity, 0, retainedGeometricBound);
} }
private static RetailPViewPassExecutor.EnvCellAlphaDrawSource Source( private static RetailPViewPassExecutor.EnvCellAlphaDrawSource Source(
@ -197,6 +349,97 @@ public sealed class EnvCellAlphaDrawSourceTests
}, },
expectedRoute); expectedRoute);
private static List<RetailAlphaEntry> ClipEntries(RetailAlphaQueue queue) =>
(List<RetailAlphaEntry>)typeof(RetailAlphaQueue)
.GetField("_clip", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(queue)!;
private static string[] DrawPipelineNames(RecordingGpuDevice device)
{
var names = new List<string>();
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
for (int i = 0; i < calls.Count; i++)
{
if (calls[i] is not GpuRecordedMultiDrawIndirect)
continue;
for (int prior = i - 1; prior >= 0; prior--)
{
if (calls[prior] is GpuRecordedPipelineBind bind)
{
names.Add(bind.PipelineName);
break;
}
}
}
return [.. names];
}
private static GpuPushConstants[] DrawPushConstants(RecordingGpuDevice device)
{
var constants = new List<GpuPushConstants>();
IReadOnlyList<GpuRecordedCall> calls = device.Calls;
for (int i = 0; i < calls.Count; i++)
{
if (calls[i] is not GpuRecordedMultiDrawIndirect)
continue;
for (int prior = i - 1; prior >= 0; prior--)
{
if (calls[prior] is GpuRecordedPushConstants push)
{
constants.Add(push.Constants);
break;
}
}
}
return [.. constants];
}
private static string RepositoryRoot()
{
DirectoryInfo? cursor = new(AppContext.BaseDirectory);
while (cursor is not null && !File.Exists(Path.Combine(cursor.FullName, "AcDream.slnx")))
cursor = cursor.Parent;
return cursor?.FullName
?? throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
}
private readonly record struct BatchSpec(
byte Mask,
bool IsTransparent,
AcDream.Core.Meshing.TranslucencyKind Translucency,
uint PaletteId)
{
internal static BatchSpec Opaque { get; } = new(
0x00,
IsTransparent: false,
AcDream.Core.Meshing.TranslucencyKind.Opaque,
PaletteId: 0);
internal static BatchSpec ClipDds { get; } = new(
RetailAlphaMeshRouter.MaskClipMap,
IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.ClipMap,
PaletteId: 0);
internal static BatchSpec ClipPaletted { get; } = new(
RetailAlphaMeshRouter.MaskClipMap,
IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.ClipMap,
PaletteId: 0x04000001u);
internal static BatchSpec ClipPositiveStippleDds { get; } = new(
RetailAlphaMeshRouter.MaskClipMap | RetailAlphaMeshRouter.MaskPositiveStipple,
IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.ClipMap,
PaletteId: 0);
internal static BatchSpec Alpha { get; } = new(
RetailAlphaMeshRouter.MaskAlphaFamily,
IsTransparent: true,
AcDream.Core.Meshing.TranslucencyKind.AlphaBlend,
PaletteId: 0);
}
/// <summary> /// <summary>
/// Recording-GPU fixture for the production EnvCell scan, dispatch and /// Recording-GPU fixture for the production EnvCell scan, dispatch and
/// filtered replay path. The two retained masks enter through the real /// filtered replay path. The two retained masks enter through the real
@ -215,9 +458,12 @@ public sealed class EnvCellAlphaDrawSourceTests
public ProductionEnvCellFixture( public ProductionEnvCellFixture(
bool detailSurfaceActive, bool detailSurfaceActive,
params byte[] retainedMasks) BatchSpec firstBatch,
BatchSpec secondBatch = default,
BatchSpec thirdBatch = default,
int ringCapacityBytes = 8 * 1024 * 1024)
{ {
Device = new RecordingGpuDevice(); Device = new RecordingGpuDevice(ringCapacityBytes);
_frames = new GpuDeviceFrameLifetime(Device); _frames = new GpuDeviceFrameLifetime(Device);
_scope = new VulkanWorldPassScope(sampleCount: 1); _scope = new VulkanWorldPassScope(sampleCount: 1);
_meshManager = new ObjectMeshManager( _meshManager = new ObjectMeshManager(
@ -236,17 +482,27 @@ public sealed class EnvCellAlphaDrawSourceTests
new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) }, new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) },
], ],
}; };
var batches = new List<TextureBatchData>(retainedMasks.Length); BatchSpec[] specs = secondBatch == default
for (int i = 0; i < retainedMasks.Length; i++) ? [firstBatch]
: thirdBatch == default
? [firstBatch, secondBatch]
: [firstBatch, secondBatch, thirdBatch];
var batches = new List<TextureBatchData>(specs.Length);
for (int i = 0; i < specs.Length; i++)
{ {
BatchSpec spec = specs[i];
batches.Add(new TextureBatchData batches.Add(new TextureBatchData
{ {
Key = new TextureKey { SurfaceId = 0x08000BFFu + (uint)i }, Key = new TextureKey
{
SurfaceId = 0x08000BFFu + (uint)i,
PaletteId = spec.PaletteId,
},
TextureData = new byte[8 * 8 * 4], TextureData = new byte[8 * 8 * 4],
Indices = [0, 1, 2], Indices = [0, 1, 2],
IsTransparent = true, IsTransparent = spec.IsTransparent,
Translucency = AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, Translucency = spec.Translucency,
RetailSurfaceMask = retainedMasks[i], RetailSurfaceMask = spec.Mask,
CullMode = CullMode.Clockwise, CullMode = CullMode.Clockwise,
IsCellShell = true, IsCellShell = true,
SourceSurfaceIndex = i, SourceSurfaceIndex = i,
@ -291,6 +547,10 @@ public sealed class EnvCellAlphaDrawSourceTests
}, },
}, },
}); });
((HashSet<uint>)typeof(EnvCellRenderer)
.GetField("_transparentCellIds", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(Renderer)!)
.Add(CellId);
Queue = new RetailAlphaQueue(); Queue = new RetailAlphaQueue();
ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
@ -299,7 +559,28 @@ public sealed class EnvCellAlphaDrawSourceTests
AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
Renderer.RenderTransparentOrdered, Renderer.RenderTransparentOrdered,
EnvCellTransparentRoute.Alpha); EnvCellTransparentRoute.Alpha);
ImmediateSink = new ProductionImmediateSink(Renderer); var passes = new RetailPViewPassExecutor(
new NullWorldPassSurface(),
NullRenderFrameGlState.Instance,
ClipFrame.NoClip(),
terrain: null,
Renderer,
(WbDrawDispatcher)RuntimeHelpers.GetUninitializedObject(typeof(WbDrawDispatcher)),
sky: null,
particles: null,
particleRenderer: null,
portalDepthMask: null,
Queue,
(WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)),
(TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject(
typeof(TerrainDrawDiagnosticsController)));
Leaf = new WalkProductionLeafRenderer(
passes,
new RetailPViewFrameInput(),
new ClipFrameAssembly(),
static () => { },
static () => { },
static () => 0);
_frames.BeginFrame(); _frames.BeginFrame();
IGpuFrame frame = _frames.CurrentFrame!; IGpuFrame frame = _frames.CurrentFrame!;
@ -323,7 +604,14 @@ public sealed class EnvCellAlphaDrawSourceTests
public RetailPViewPassExecutor.EnvCellAlphaDrawSource AlphaSource { get; } public RetailPViewPassExecutor.EnvCellAlphaDrawSource AlphaSource { get; }
public ProductionImmediateSink ImmediateSink { get; } public WalkProductionLeafRenderer Leaf { get; }
public void RunWholeLeafFrame()
{
Queue.BeginFrame();
Leaf.DrawCellShell(CellId);
Queue.EndFrame();
}
private static bool DetailOn() => true; private static bool DetailOn() => true;
@ -339,22 +627,15 @@ public sealed class EnvCellAlphaDrawSourceTests
} }
} }
private sealed class ProductionImmediateSink(EnvCellRenderer renderer) : private sealed class NullWorldPassSurface : IWorldPassSurface
IEnvCellImmediateDrawSink
{ {
public int DrawCount { get; private set; } public void PrepareClipFrame() { }
public bool LastDetailSurfaceActive { get; private set; } public void EnableClipDistances() { }
public void DrawImmediate( public void DisableClipDistances() { }
uint cellId,
EnvCellTransparentRoute route, public void ClearInteriorDepth() => throw new InvalidOperationException();
bool detailSurfaceActive)
{
DrawCount++;
LastDetailSurfaceActive = detailSurfaceActive;
renderer.RenderTransparentOrdered([cellId], route, detailSurfaceActive);
}
} }
private sealed class NullPreparedAssetSource : IPreparedAssetSource private sealed class NullPreparedAssetSource : IPreparedAssetSource
@ -395,20 +676,4 @@ public sealed class EnvCellAlphaDrawSourceTests
} }
} }
private sealed class NoAllocRouteSink
{
public void Render(
IReadOnlyList<uint> cells,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
}
public void DrawImmediate(
uint cellId,
EnvCellTransparentRoute route,
bool detailSurfaceActive)
{
}
}
} }

View file

@ -74,16 +74,17 @@ public sealed class GpuContractTests
} }
[Fact] [Fact]
public void BlendModesCoverEveryRetailTranslucencyKind() public void BlendModesCoverEveryRetailRendererState()
{ {
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT // WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
// surface's TranslucencyKind. Retail has three, and the V0 contract shipped // surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a
// with only two — slice V4c found the gap. Mapping InvAlpha onto // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while
// StraightAlpha would silently change how every inverse-alpha surface // the detail combine is a separate renderer state. Collapsing any of
// composites, so the contract has to carry all three. // these modes changes the corresponding subset's composition.
Assert.Equal(5, Enum.GetValues<GpuBlendMode>().Length); Assert.Equal(6, Enum.GetValues<GpuBlendMode>().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues<GpuBlendMode>()); Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues<GpuBlendMode>());

View file

@ -135,6 +135,14 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant
/// <summary>Every recorded call, in submission order.</summary> /// <summary>Every recorded call, in submission order.</summary>
public IReadOnlyList<GpuRecordedCall> Calls => _calls; public IReadOnlyList<GpuRecordedCall> Calls => _calls;
/// <summary>
/// Allocation measurements may drive the real renderer/RHI submission
/// path without allocating one diagnostic record per call. Resource,
/// frame, ring, and encoder behavior stays identical; only call-record
/// object construction is suppressed when this is false.
/// </summary>
public bool RecordingEnabled { get; set; } = true;
/// <summary>Backing store for ring allocations, so tests can read what a renderer wrote.</summary> /// <summary>Backing store for ring allocations, so tests can read what a renderer wrote.</summary>
public ReadOnlySpan<byte> RingBytes => _ring; public ReadOnlySpan<byte> RingBytes => _ring;
@ -410,7 +418,8 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant
} }
_ringCursor = aligned + (uint)byteCount; _ringCursor = aligned + (uint)byteCount;
_calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned)); if (RecordingEnabled)
_calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned));
return new GpuRingAllocation(RingBuffer, aligned, _ring.AsSpan((int)aligned, byteCount)); return new GpuRingAllocation(RingBuffer, aligned, _ring.AsSpan((int)aligned, byteCount));
} }
@ -460,7 +469,8 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial,
"Published host writes require a host-writable storage buffer.", "Published host writes require a host-writable storage buffer.",
nameof(buffer)); nameof(buffer));
} }
device.Record(new GpuRecordedHostStorageVisibility(buffer.Name)); if (device.RecordingEnabled)
device.Record(new GpuRecordedHostStorageVisibility(buffer.Name));
} }
public IGpuPassEncoder BeginPass(GpuPassDescription description) public IGpuPassEncoder BeginPass(GpuPassDescription description)
@ -549,60 +559,97 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass
throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match."); throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match.");
if (pipeline.Description.ViewMask != Pass.ViewMask) if (pipeline.Description.ViewMask != Pass.ViewMask)
throw new InvalidOperationException("Pipeline and pass view masks must match."); throw new InvalidOperationException("Pipeline and pass view masks must match.");
device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name)); if (device.RecordingEnabled)
device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name));
} }
public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{ {
ArgumentNullException.ThrowIfNull(buffer); ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedStorageBind(binding, buffer.Name, offsetBytes, sizeBytes)); if (device.RecordingEnabled)
device.Record(new GpuRecordedStorageBind(binding, buffer.Name, offsetBytes, sizeBytes));
} }
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{ {
ArgumentNullException.ThrowIfNull(buffer); ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes)); if (device.RecordingEnabled)
device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes));
} }
public void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes) public void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes)
{ {
ArgumentNullException.ThrowIfNull(buffer); ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedVertexBind(binding, buffer.Name, offsetBytes)); if (device.RecordingEnabled)
device.Record(new GpuRecordedVertexBind(binding, buffer.Name, offsetBytes));
} }
public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType)
{ {
ArgumentNullException.ThrowIfNull(buffer); ArgumentNullException.ThrowIfNull(buffer);
device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType)); if (device.RecordingEnabled)
device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType));
} }
public void SetPushConstants(in GpuPushConstants constants) => public void SetPushConstants(in GpuPushConstants constants)
device.Record(new GpuRecordedPushConstants(constants)); {
if (device.RecordingEnabled)
device.Record(new GpuRecordedPushConstants(constants));
}
public void SetViewport(int x, int y, int width, int height) => public void SetViewport(int x, int y, int width, int height)
device.Record(new GpuRecordedViewport(x, y, width, height)); {
if (device.RecordingEnabled)
device.Record(new GpuRecordedViewport(x, y, width, height));
}
public void SetScissor(int x, int y, int width, int height) => public void SetScissor(int x, int y, int width, int height)
device.Record(new GpuRecordedScissor(x, y, width, height)); {
if (device.RecordingEnabled)
device.Record(new GpuRecordedScissor(x, y, width, height));
}
public void SetCullMode(GpuCullMode cullMode) => device.Record(new GpuRecordedCullMode(cullMode)); public void SetCullMode(GpuCullMode cullMode)
{
if (device.RecordingEnabled)
device.Record(new GpuRecordedCullMode(cullMode));
}
public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace)); public void SetFrontFace(GpuFrontFace frontFace)
{
if (device.RecordingEnabled)
device.Record(new GpuRecordedFrontFace(frontFace));
}
public void SetStencil(in GpuStencilState stencil) => device.Record(new GpuRecordedStencil(stencil)); public void SetStencil(in GpuStencilState stencil)
{
if (device.RecordingEnabled)
device.Record(new GpuRecordedStencil(stencil));
}
public void SetDepthWrite(bool enabled) => device.Record(new GpuRecordedDepthWrite(enabled)); public void SetDepthWrite(bool enabled)
{
if (device.RecordingEnabled)
device.Record(new GpuRecordedDepthWrite(enabled));
}
public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) => public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance)
device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance)); {
if (device.RecordingEnabled)
device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance));
}
public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) => public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance)
device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance)); {
if (device.RecordingEnabled)
device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance));
}
public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes) public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
{ {
ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(commands);
device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes)); if (device.RecordingEnabled)
device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes));
} }
public IDisposable BeginTimerScope(string scopeName) public IDisposable BeginTimerScope(string scopeName)

View file

@ -41,7 +41,13 @@ public sealed class VulkanShaderManifestTests
{ {
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140",
["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e", // Re-pinned 2026-09-04 (Campaign OVERHAUL S4-c2 owner-authorized
// repair): Vulkan expresses SetSurface's pure-ClipMap alpha test
// through uParamB, recognizing only the paletted 100/255 and DDS
// 200/255 references and using discard-on-less so equality passes.
// The paired binary selects those globals and
// D3DCMP_GREATEREQUAL at 0x0059c73c..0x0059c838.
["mesh_modern.frag.spv"] = "1b3a59d0cc7c5bb6acf793d57d9673d860a7eb50bdd51770781f59feac4604a1",
// mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1, // mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1,
// F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its // F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its
// gl_ClipDistance loop, and the gl_PerVertex redeclaration are // gl_ClipDistance loop, and the gl_PerVertex redeclaration are

View file

@ -132,11 +132,14 @@ public sealed class VulkanViewportMappingTests
} }
[Fact] [Fact]
public void AllThreeRetailBlendModesAreRepresentable() public void AllRetailBlendModesAreRepresentable()
{ {
Assert.Equal( Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha)); VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha));
Assert.Equal(
(BlendFactor.One, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.PremultipliedAlpha));
Assert.Equal( Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.One), (BlendFactor.SrcAlpha, BlendFactor.One),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive)); VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive));

View file

@ -1,3 +1,4 @@
using System.Collections;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -9,6 +10,7 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using Chorizite.Core.Render.Enums;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
@ -189,54 +191,110 @@ public sealed class ParticleRendererRouteTests
} }
[Fact] [Fact]
public void ProductionDispatch_WarmedAppendAndImmediatePathsDoNotAllocate() public void ProductionImmediateMesh_WarmedDrawImmediateParticleSubmissionRhiDoesNotAllocate()
{ {
var queue = new RetailAlphaQueue(); using var device = new RecordingGpuDevice(64 * 1024 * 1024);
var sink = new NoAllocParticleSink(); using var manager = new ObjectMeshManager(
ParticleRenderer.ReserveDeferredParticleDraw reserve = sink.Reserve; new VulkanMeshPipelineDevice(device.Retirement),
ParticleRenderer.DrawImmediateParticle immediate = sink.DrawImmediate; device,
new NullPreparedAssetSource(),
NullLogger<ObjectMeshManager>.Instance);
ObjectRenderData renderData = Assert.IsType<ObjectRenderData>(manager.UploadMeshData(
new ObjectMeshData
{
ObjectId = 0x010001ECu,
Vertices =
[
new VertexPositionNormalTexture { Position = Vector3.Zero },
new VertexPositionNormalTexture { Position = Vector3.UnitX },
new VertexPositionNormalTexture { Position = Vector3.UnitY },
],
TextureBatches =
{
[(8, 8, TextureFormat.RGBA8)] =
[
new TextureBatchData
{
Key = new TextureKey { SurfaceId = 0x08000015u },
TextureData = new byte[8 * 8 * 4],
Indices = [0, 1, 2],
Translucency = TranslucencyKind.Opaque,
CullMode = DatReaderWriter.Enums.CullMode.Clockwise,
},
],
},
}));
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);
for (int i = 0; i < 64; i++) Type keyType = typeof(ParticleRenderer).GetNestedType(
RunPair(queue, sink, reserve, immediate); "MeshBatchKey", BindingFlags.NonPublic)!;
Type instanceType = typeof(ParticleRenderer).GetNestedType(
"MeshParticleInstance", BindingFlags.NonPublic)!;
Type drawType = typeof(ParticleRenderer).GetNestedType(
"MeshParticleDraw", BindingFlags.NonPublic)!;
object key = Activator.CreateInstance(
keyType,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [0x010001ECu, 0],
culture: null)!;
object instance = Activator.CreateInstance(
instanceType,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [Matrix4x4.Identity, 0xFFFFFFFFu, 0f, 0u],
culture: null)!;
object draw = Activator.CreateInstance(
drawType,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [key, renderData.Batches[0], instance],
culture: null)!;
var drawList = (IList)typeof(ParticleRenderer).GetField(
"_meshDrawListScratch",
BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(renderer)!;
drawList.Add(draw);
var immediate = (ParticleRenderer.DrawImmediateParticle)typeof(ParticleRenderer)
.GetField("_drawImmediateParticle", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(renderer)!;
long before = GC.GetAllocatedBytesForCurrentThread(); frames.BeginFrame();
for (int i = 0; i < 1_000; i++) renderer.BeginFrame(frameSlot: 0);
RunPair(queue, sink, reserve, immediate); IGpuFrame frame = frames.CurrentFrame!;
long allocated = GC.GetAllocatedBytesForCurrentThread() - before; using IGpuPassEncoder pass = frame.BeginPass(GpuPassDescription.BackbufferClear(
"s4-c2-particle-allocation",
Vector4.Zero,
sampleCount: 1));
using IDisposable publication = scope.Publish(pass);
device.Clear();
device.RecordingEnabled = false;
long allocated = ZeroAllocationProbe.MeasureWarmed(
() => immediate(
Matrix4x4.Identity,
ParticleSubmissionKind.Mesh,
drawIndex: 0,
opaqueDepthState: true),
batchSize: 256,
warmupBatches: 2,
samples: 4);
Assert.Equal(0, allocated); 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 private sealed class RecordingSource : IRetailAlphaDrawSource
{ {
public int PrepareCount { get; private set; } public int PrepareCount { get; private set; }
@ -252,31 +310,6 @@ public sealed class ParticleRendererRouteTests
} }
} }
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<int> tokens)
{
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
}
public void ResetAlphaSubmissions() => _nextToken = 0;
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource private sealed class NullPreparedAssetSource : IPreparedAssetSource
{ {
public PreparedAssetSourceStats Stats => default; public PreparedAssetSourceStats Stats => default;

View file

@ -240,9 +240,10 @@ public sealed class RetailAlphaMeshRouterTests
/// <summary> /// <summary>
/// <see cref="RetailAlphaMeshRouter.MaskFromTranslucencyKind"/>'s /// <see cref="RetailAlphaMeshRouter.MaskFromTranslucencyKind"/>'s
/// reachable mapping (opaque/ClipMap are pre-filtered upstream and never /// complete mapping. Ordinary Wb callers pre-filter opaque/ClipMap and
/// actually call this from a live site, but the function itself must /// retain AP-239's earlier subset-mask reconstruction distinction. Mesh
/// still map them defensively). /// particles, however, genuinely call this method without that ordinary-
/// Wb pre-filter, including for opaque-classified batches.
/// </summary> /// </summary>
[Theory] [Theory]
[InlineData(TranslucencyKind.AlphaBlend, RetailAlphaMeshRouter.MaskAlphaFamily)] [InlineData(TranslucencyKind.AlphaBlend, RetailAlphaMeshRouter.MaskAlphaFamily)]