diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 448b0518..4cb3a2f4 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -330,9 +330,9 @@ research and is no longer active.
| ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| ~~AP-34~~ | **RETIRED 2026-09-04 (S4-c2 fix round 1) — residuals AP-238 / AP-239 / AP-240.** Historical: a single acdream-only distance-sorted alpha queue stood in for retail's two independent per-`DrawMesh`-invocation FIFO lists (CLIP/ALPHA, capacity 3000 each) drained at retail's four normal-world `FlushAlphaList` sites (`DrawBuilding`/`DrawBlock`/`PView::DrawCells`/`RenderNormalMode`). S4-c2 (`048d5b12f`) replaced it with `RetailAlphaQueue`'s exact two-list FIFO port of `D3DPolyRender::AddMeshToAlphaList`'s append-only/capacity-drop behavior, routed by `RetailAlphaMeshRouter`'s port of `DrawMesh`'s five-row branch table, under retail's exact no-op threshold rule — no acdream-only sort remains anywhere on the alpha path. | retired | — | — | `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `D3DPolyRender::DrawMesh` 0x0059D4A0 |
-| AP-238 | **S4-c2 (2026-09-04); NARROWED FURTHER at 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-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-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
index 444daa9e..e8508e29 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
@@ -121,6 +121,14 @@ internal enum GpuBlendMode
/// Straight alpha: SrcAlpha, OneMinusSrcAlpha.
StraightAlpha,
+ ///
+ /// Premultiplied/source-over: One, OneMinusSrcAlpha. Retail pure
+ /// Base1ClipMap surfaces select exactly this pair in
+ /// D3DPolyRender::SetSurface @0x0059c72a when the per-entry
+ /// override-clipmap byte is clear.
+ ///
+ PremultipliedAlpha,
+
/// Additive: SrcAlpha, One.
Additive,
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
index 5beb3de2..ca130289 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
@@ -174,6 +174,7 @@ internal static class VulkanViewportMapping
internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch
{
GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
+ GpuBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
index 13b65b6f..e8206adc 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
@@ -131,7 +131,7 @@ public sealed unsafe partial class ParticleRenderer
///
/// The RHI arm's constructor. No GL context, no Shader, no
- /// BindlessSupport: the five pipelines compile particle and
+ /// BindlessSupport: the six pipelines compile particle and
/// particle_mesh from the committed SPIR-V, and both texture sources
/// already hand out the device's own GpuTextureSlot (V4t).
///
@@ -267,9 +267,11 @@ public sealed unsafe partial class ParticleRenderer
});
///
- /// One mesh-particle pipeline. Same depth bracket as the billboards (see
- /// for the world
- /// GL_LESS citation); the winding is CW because
+ /// One mesh-particle pipeline. Every variant uses retail's world
+ /// GL_LESS compare; delayed alpha variants match the billboard
+ /// no-write bracket, while row 5's opaque variant depth-writes. See
+ /// for the compare
+ /// citation. The winding is CW because
/// PrepareMeshPipeline sets glFrontFace(GL_CW), and the cull
/// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
/// own CullMode.
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index ff9f18b7..7a5d1159 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -263,13 +263,31 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
if ((routes & EnvCellTransparentRoute.Clip) != 0)
{
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)
{
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)
{
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)
{
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,
bool detailSurfaceActive);
- internal sealed class EnvCellAlphaDrawSource(
- RenderEnvCellsByRoute renderTransparentOrdered,
- EnvCellTransparentRoute route)
- : IRetailAlphaDrawSource
+ internal sealed class EnvCellAlphaDrawSource : IRetailAlphaDrawSource
{
+ private readonly RenderEnvCellsByRoute _renderTransparentOrdered;
+ private readonly EnvCellTransparentRoute _route;
+ private readonly Action? _resetObserver;
private readonly List _pendingCellIds = new();
private readonly List _preparedCellIds = new();
private readonly List _drawScratch = new();
+ internal EnvCellAlphaDrawSource(
+ RenderEnvCellsByRoute renderTransparentOrdered,
+ EnvCellTransparentRoute route,
+ Action? resetObserver = null)
+ {
+ _renderTransparentOrdered = renderTransparentOrdered
+ ?? throw new ArgumentNullException(nameof(renderTransparentOrdered));
+ _route = route;
+ _resetObserver = resetObserver;
+ }
+
/// Returns this cell's token — its index into
/// at the moment it was added.
internal int AddPendingCellId(uint cellId)
@@ -345,6 +388,22 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
return token;
}
+ ///
+ /// 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.
+ ///
+ 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 tokens)
{
_preparedCellIds.Clear();
@@ -359,7 +418,10 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_drawScratch.Clear();
for (int i = 0; i < drawCount; i++)
_drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]);
- renderTransparentOrdered(_drawScratch, route, detailSurfaceActive: false);
+ _renderTransparentOrdered(
+ _drawScratch,
+ _route,
+ detailSurfaceActive: false);
}
public void ResetAlphaSubmissions()
@@ -367,9 +429,13 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin
_pendingCellIds.Clear();
_preparedCellIds.Clear();
_drawScratch.Clear();
+ _resetObserver?.Invoke();
}
internal int PendingCount => _pendingCellIds.Count;
+ internal int PendingCapacity => _pendingCellIds.Capacity;
+ internal int PreparedCapacity => _preparedCellIds.Capacity;
+ internal int DrawCapacity => _drawScratch.Capacity;
}
///
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag
index 380c0c22..8d6f09de 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag
@@ -11,6 +11,10 @@ in flat uint vBatchFlags;
in flat uint vDetailCategory;
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
// std140 block, same struct, same binding). Declared here ONLY for fog
@@ -50,6 +54,11 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
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
// 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports
// m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1,
@@ -100,7 +109,8 @@ void main() {
vec4 base = ACDREAM_SAMPLE_ARRAY(
vBaseTextureIndex,
vec3(vBaseUv, float(vBaseTextureLayer)));
- if (base.a < 0.05)
+ float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05;
+ if (base.a < alphaCutoff)
discard;
vec4 detail = ACDREAM_SAMPLE_ARRAY(
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert
index 6c56e6df..318c6587 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert
@@ -38,9 +38,9 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
-// Object renderer only: 1 for a retail building shell, 0 for ordinary
-// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the
-// value, but still binds one valid word because Vulkan sees this static use.
+// Exact uParamB=1 selects this per-instance building/object category. EnvCell
+// zero and its pure-ClipMap 100/255 or 200/255 references retain the bound
+// environment category; the fragment shader consumes those CLIP references.
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
uint instanceDetailCategory[];
};
@@ -61,7 +61,10 @@ uniform mat4 uViewProjection;
uniform int uDrawIDOffset;
uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
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 vDetailUv;
@@ -95,7 +98,7 @@ void main() {
vBaseTextureIndex = batch.textureIndex;
vBaseTextureLayer = batch.textureLayer;
vBatchFlags = batch.flags;
- vDetailCategory = uParamB > 0.5
+ vDetailCategory = uParamB == 1.0
? instanceDetailCategory[instanceIndex]
: 1u;
}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
index 447ce2e3..5e5d97e4 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
@@ -22,6 +22,10 @@ in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
// transparent overdraw cost otherwise)
uniform int uRenderPass;
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.
struct Light {
@@ -57,15 +61,23 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
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() {
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).
// 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.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
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 {
// Transparent pass.
//
@@ -84,7 +96,7 @@ void main() {
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// 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
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv
index caf4e76d..b8fbf8e9 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv
index 50650461..5ae4f237 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv
index 04fa405e..337ecaeb 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index bb310ded..b4118fd9 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -231,12 +231,12 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "0c1eddb0fc892cb76ef4da47f727cec2516052d63247fa2682918bfd60b84fa8",
+ "sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181",
"compiled": true
},
{
"stage": "frag",
- "sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1",
+ "sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335",
"compiled": true
}
]
@@ -252,7 +252,7 @@
},
{
"stage": "frag",
- "sourceSha256": "12f2ceec9a8420bd273650e95251ca56c3bf3c3691fce1a539e4c5aea7abaabf",
+ "sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e",
"compiled": true
}
]
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
index 5524019b..d5050553 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
@@ -26,9 +26,11 @@ public sealed unsafe partial class EnvCellRenderer
private readonly IWorldPassScope? _scope;
private IGpuPipeline? _opaquePipeline;
private IGpuPipeline? _alphaPipeline;
+ private IGpuPipeline? _clipPipeline;
private IGpuPipeline? _additivePipeline;
private IGpuPipeline? _detailPipeline;
private IGpuPipeline? _transparentDetailPipeline;
+ private IGpuPipeline? _clipDetailPipeline;
private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
private readonly Func _buildingDetailEnabled;
@@ -48,7 +50,7 @@ public sealed unsafe partial class EnvCellRenderer
///
/// The RHI arm's constructor. It also completes Initialize'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 Shader to hand in.
///
internal EnvCellRenderer(
@@ -72,6 +74,12 @@ public sealed unsafe partial class EnvCellRenderer
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
_alphaPipeline = CreateShellPipeline(
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount);
+ _clipPipeline = CreateShellPipeline(
+ device,
+ "envcell-clip",
+ GpuBlendMode.PremultipliedAlpha,
+ depthWrite: true,
+ scope.SampleCount);
_additivePipeline = CreateShellPipeline(
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
_detailPipeline = CreateShellPipeline(
@@ -92,6 +100,15 @@ public sealed unsafe partial class EnvCellRenderer
shaderName: "mesh_detail",
depthCompare: RetailDetailTextureContract.DetailDepthCompare(
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;
}
@@ -274,12 +291,18 @@ public sealed unsafe partial class EnvCellRenderer
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
int groupIndex = drawRange.GroupIndex;
CullMode cullMode = ResolveRetailCellShellCullMode(
- (CullMode)(groupIndex % 4));
+ (CullMode)(groupIndex % CullGroupCount));
- bool isAdditive = groupIndex >= 4;
- IGpuPipeline rangeBasePipeline = isAdditive
- ? _additivePipeline!
- : _alphaPipeline!;
+ bool isAdditive = renderPass == WbRenderPass.Transparent
+ && groupIndex >= AdditiveGroupBase
+ && groupIndex < ClipDdsGroupBase;
+ bool isClip = renderPass == WbRenderPass.Transparent
+ && groupIndex >= ClipDdsGroupBase;
+ IGpuPipeline rangeBasePipeline = isClip
+ ? _clipPipeline!
+ : isAdditive
+ ? _additivePipeline!
+ : _alphaPipeline!;
if (renderPass == WbRenderPass.Transparent)
{
// 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;
pushConstants.DrawIdOffset = drawRange.FirstCommand;
+ pushConstants.ParamB = isClip
+ ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
+ : 0f;
encoder.SetPushConstants(in pushConstants);
// Retail DrawMesh's two-pass fallback redraws each transparent
@@ -317,7 +343,9 @@ public sealed unsafe partial class EnvCellRenderer
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = 0;
pushConstants.ParamA = 0f;
- pushConstants.ParamB = 0f;
+ pushConstants.ParamB = isClip
+ ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
+ : 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
@@ -325,12 +353,17 @@ public sealed unsafe partial class EnvCellRenderer
1,
(uint)sizeof(DrawElementsIndirectCommand));
- BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh);
+ BindPipelineWithMesh(
+ encoder,
+ isClip ? _clipDetailPipeline! : _transparentDetailPipeline!,
+ mesh);
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = command;
pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
pushConstants.ParamA = _environmentDetail.Tiling;
- pushConstants.ParamB = 0f;
+ pushConstants.ParamB = isClip
+ ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f
+ : 0f;
encoder.SetPushConstants(in pushConstants);
encoder.MultiDrawIndexedIndirect(
commandBuffer,
@@ -348,9 +381,11 @@ public sealed unsafe partial class EnvCellRenderer
(uint)sizeof(DrawElementsIndirectCommand));
}
- // Retail DrawEnvCell category (2). Replay the already-filtered opaque
- // shell commands, including ClipMap built-mesh subsets. No distance
- // fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the
+ // Retail DrawEnvCell category (2). Replay only the already-filtered
+ // opaque shell commands. Transparent ClipMap is excluded here: it
+ // 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;
// attenuation is the sampler's linear mip chain converging to the
// texture mean. The existing "Building Detail Textures" option gates
@@ -368,7 +403,7 @@ public sealed unsafe partial class EnvCellRenderer
{
MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
CullMode cullMode = ResolveRetailCellShellCullMode(
- (CullMode)(drawRange.GroupIndex % 4));
+ (CullMode)(drawRange.GroupIndex % CullGroupCount));
SetCullMode(encoder, cullMode);
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
@@ -468,9 +503,10 @@ public sealed unsafe partial class EnvCellRenderer
///
/// Binds one category word for mesh_detail.vert's statically used
- /// binding 9. EnvCell draws select their renderer-wide category via
- /// uParamB=0, so the value is semantically unused, but Vulkan still
- /// requires the declared descriptor to be valid.
+ /// binding 9. The vertex shader treats only exact uParamB=1 as the
+ /// object/building-category sentinel; ordinary EnvCell zero and the CLIP
+ /// references 100/255 and 200/255 therefore keep the environment category.
+ /// Vulkan still requires the declared descriptor to be valid.
///
internal static void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder,
@@ -491,11 +527,15 @@ public sealed unsafe partial class EnvCellRenderer
_opaquePipeline = null;
_alphaPipeline?.Dispose();
_alphaPipeline = null;
+ _clipPipeline?.Dispose();
+ _clipPipeline = null;
_additivePipeline?.Dispose();
_additivePipeline = null;
_detailPipeline?.Dispose();
_detailPipeline = null;
_transparentDetailPipeline?.Dispose();
_transparentDetailPipeline = null;
+ _clipDetailPipeline?.Dispose();
+ _clipDetailPipeline = null;
}
}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index 1b0f3562..3830ee9a 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -142,8 +142,17 @@ public sealed partial class EnvCellRenderer :
private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new();
private readonly Dictionary> _filteredGroups = new();
private readonly HashSet> _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 =
- Enumerable.Range(0, 8)
+ Enumerable.Range(0, BatchGroupCount)
.Select(_ => new List<(ObjectRenderBatch, int, int)>())
.ToArray();
private readonly List _activeCullGroups = new(8);
@@ -1214,17 +1223,8 @@ public sealed partial class EnvCellRenderer :
foreach (var batch in call.renderData.Batches)
{
// WB BaseObjectRenderManager.cs:723-731: pass-filter.
- if (renderPass != WbRenderPass.SinglePass)
- {
- if (batch.IsAdditive)
- {
- if (renderPass == WbRenderPass.Opaque) continue;
- }
- else if (!batch.IsTransparent)
- {
- if (renderPass == WbRenderPass.Transparent) continue;
- }
- }
+ if (!BatchBelongsToPass(batch, renderPass))
+ continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
@@ -1277,17 +1277,8 @@ public sealed partial class EnvCellRenderer :
var call = drawCalls[callIndex];
foreach (var batch in call.renderData.Batches)
{
- if (renderPass != WbRenderPass.SinglePass)
- {
- if (batch.IsAdditive)
- {
- if (renderPass == WbRenderPass.Opaque) continue;
- }
- else if (!batch.IsTransparent)
- {
- if (renderPass == WbRenderPass.Transparent) continue;
- }
- }
+ if (!BatchBelongsToPass(batch, renderPass))
+ continue;
if (renderPass == WbRenderPass.Transparent
&& transparentRoute != EnvCellTransparentRoute.All
@@ -1296,7 +1287,9 @@ public sealed partial class EnvCellRenderer :
continue;
}
- int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0);
+ int groupIndex = ResolveBatchGroupIndex(
+ batch,
+ renderPass);
List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group =
_batchesByCullGroup[groupIndex];
if (group.Count == 0)
@@ -1357,6 +1350,50 @@ public sealed partial class EnvCellRenderer :
SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount);
}
+ ///
+ /// Exact WB pass membership, with
+ /// intentionally unchanged. A non-additive transparent batch is delayed
+ /// content and must never draw during the preceding opaque cell turn.
+ ///
+ 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(
List ranges,
int groupIndex,
diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
index 0f032831..5e5c2df4 100644
--- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs
@@ -1,4 +1,6 @@
using System.Numerics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
@@ -38,44 +40,38 @@ public sealed class EnvCellAlphaDrawSourceTests
}
///
- /// A mixed cell creates one token in each list and neither set draws before
- /// the real queue drain. CLIP drains first and receives only the CLIP
- /// replay filter; ALPHA receives only ALPHA. Mutation checks: inverting the
- /// detail-off immediate predicate makes Assert.Empty(log) fail;
- /// removing the replay filter makes either route assertion fail.
+ /// The real walk leaf executes the preceding opaque pass, dispatch, and
+ /// queue drain. The opaque subset draws at the turn; CLIP and ALPHA do not.
+ /// Each delayed list then replays exactly its own subset.
[Fact]
- public void MixedCell_DefersOneTokenPerList_AndReplayIsListFiltered()
+ public void WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain()
{
using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: false,
- 0x08,
- 0x02);
- EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
- ProductionEnvCellFixture.CellId,
- detailSurfaceActive: false);
+ BatchSpec.Opaque,
+ BatchSpec.ClipDds,
+ BatchSpec.Alpha);
fixture.Queue.BeginFrame();
- RetailPViewPassExecutor.DispatchTransparentCellShell(
- ProductionEnvCellFixture.CellId,
- routes,
- detailSurfaceActive: false,
- fixture.Queue,
- fixture.ClipSource,
- fixture.AlphaSource,
- fixture.ImmediateSink);
+ fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
- Assert.Equal(EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Alpha, routes);
- Assert.Empty(fixture.Device.Calls.OfType());
+ GpuRecordedMultiDrawIndirect turnDraw = Assert.Single(
+ fixture.Device.Calls.OfType());
+ Assert.Equal(1u, turnDraw.DrawCount);
+ Assert.Equal(["envcell-opaque"], DrawPipelineNames(fixture.Device));
Assert.Equal(1, fixture.Queue.ClipCount);
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 =
[.. fixture.Device.Calls.OfType()];
- Assert.Equal(2, draws.Length);
+ Assert.Equal(3, draws.Length);
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]
@@ -83,28 +79,29 @@ public sealed class EnvCellAlphaDrawSourceTests
{
using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: true,
- 0x08);
- EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes(
- ProductionEnvCellFixture.CellId,
- detailSurfaceActive: true);
+ BatchSpec.ClipDds);
fixture.Queue.BeginFrame();
- RetailPViewPassExecutor.DispatchTransparentCellShell(
- ProductionEnvCellFixture.CellId,
- routes,
- detailSurfaceActive: true,
- fixture.Queue,
- fixture.ClipSource,
- fixture.AlphaSource,
- fixture.ImmediateSink);
+ fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
- Assert.Equal(EnvCellTransparentRoute.Immediate, routes);
- Assert.Equal(1, fixture.ImmediateSink.DrawCount);
- Assert.True(fixture.ImmediateSink.LastDetailSurfaceActive);
Assert.Equal(2, fixture.Device.Calls.OfType().Count());
+ Assert.Equal(
+ ["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);
fixture.Queue.EndFrame();
- Assert.Equal(1, fixture.ImmediateSink.DrawCount);
+ Assert.Equal(2, fixture.Device.Calls.OfType().Count());
}
/// Two cell tokens from the SAME ,
@@ -141,47 +138,202 @@ public sealed class EnvCellAlphaDrawSourceTests
}
[Fact]
- public void ProductionDispatch_WarmedImmediateAndFilteredSourcesDoNotAllocate()
+ public void ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate()
{
- var sink = new NoAllocRouteSink();
- var clipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
- sink.Render,
- EnvCellTransparentRoute.Clip);
- var alphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
- sink.Render,
- EnvCellTransparentRoute.Alpha);
- RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate = sink.DrawImmediate;
- var queue = new RetailAlphaQueue();
+ using var fixture = new ProductionEnvCellFixture(
+ detailSurfaceActive: false,
+ BatchSpec.Opaque,
+ BatchSpec.ClipDds,
+ BatchSpec.Alpha,
+ ringCapacityBytes: 64 * 1024 * 1024);
- for (int i = 0; i < 64; i++)
- RunMixed(queue, clipSource, alphaSource, immediate);
-
- long before = GC.GetAllocatedBytesForCurrentThread();
- for (int i = 0; i < 1_000; i++)
- RunMixed(queue, clipSource, alphaSource, immediate);
- long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ fixture.Device.Clear();
+ fixture.Device.RecordingEnabled = false;
+ long allocated = ZeroAllocationProbe.MeasureWarmed(
+ fixture.RunWholeLeafFrame,
+ batchSize: 256,
+ warmupBatches: 2,
+ samples: 4);
Assert.Equal(0, allocated);
}
- private static void RunMixed(
- RetailAlphaQueue queue,
- RetailPViewPassExecutor.EnvCellAlphaDrawSource clipSource,
- RetailPViewPassExecutor.EnvCellAlphaDrawSource alphaSource,
- RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate)
+ [Theory]
+ [InlineData(false, 200f / 255f)]
+ [InlineData(true, 100f / 255f)]
+ public void WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference(
+ bool paletted,
+ float expectedReference)
{
- queue.BeginFrame();
- RetailPViewPassExecutor.DispatchTransparentCellShell(
- 0xF4180104u,
- EnvCellTransparentRoute.Immediate
- | EnvCellTransparentRoute.Clip
- | EnvCellTransparentRoute.Alpha,
+ BatchSpec clip = paletted ? BatchSpec.ClipPaletted : BatchSpec.ClipDds;
+ using var fixture = new ProductionEnvCellFixture(
detailSurfaceActive: false,
- queue,
- clipSource,
- alphaSource,
- immediate);
- queue.EndFrame();
+ clip);
+
+ fixture.Queue.BeginFrame();
+ fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId);
+ Assert.Empty(fixture.Device.Calls.OfType());
+ 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());
+ 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);
+ }
+
+ ///
+ /// Vulkan has no fixed-function alpha test. The shader's discard-on-less
+ /// spelling is exactly GREATER_EQUAL: equality survives. Changing either
+ /// comparison to <= (strict GREATER) fails this named source+manifest pin.
+ ///
+ [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(
@@ -197,6 +349,97 @@ public sealed class EnvCellAlphaDrawSourceTests
},
expectedRoute);
+ private static List ClipEntries(RetailAlphaQueue queue) =>
+ (List)typeof(RetailAlphaQueue)
+ .GetField("_clip", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .GetValue(queue)!;
+
+ private static string[] DrawPipelineNames(RecordingGpuDevice device)
+ {
+ var names = new List();
+ IReadOnlyList 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();
+ IReadOnlyList 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);
+ }
+
///
/// Recording-GPU fixture for the production EnvCell scan, dispatch and
/// filtered replay path. The two retained masks enter through the real
@@ -215,9 +458,12 @@ public sealed class EnvCellAlphaDrawSourceTests
public ProductionEnvCellFixture(
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);
_scope = new VulkanWorldPassScope(sampleCount: 1);
_meshManager = new ObjectMeshManager(
@@ -236,17 +482,27 @@ public sealed class EnvCellAlphaDrawSourceTests
new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) },
],
};
- var batches = new List(retainedMasks.Length);
- for (int i = 0; i < retainedMasks.Length; i++)
+ BatchSpec[] specs = secondBatch == default
+ ? [firstBatch]
+ : thirdBatch == default
+ ? [firstBatch, secondBatch]
+ : [firstBatch, secondBatch, thirdBatch];
+ var batches = new List(specs.Length);
+ for (int i = 0; i < specs.Length; i++)
{
+ BatchSpec spec = specs[i];
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],
Indices = [0, 1, 2],
- IsTransparent = true,
- Translucency = AcDream.Core.Meshing.TranslucencyKind.AlphaBlend,
- RetailSurfaceMask = retainedMasks[i],
+ IsTransparent = spec.IsTransparent,
+ Translucency = spec.Translucency,
+ RetailSurfaceMask = spec.Mask,
CullMode = CullMode.Clockwise,
IsCellShell = true,
SourceSurfaceIndex = i,
@@ -291,6 +547,10 @@ public sealed class EnvCellAlphaDrawSourceTests
},
},
});
+ ((HashSet)typeof(EnvCellRenderer)
+ .GetField("_transparentCellIds", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .GetValue(Renderer)!)
+ .Add(CellId);
Queue = new RetailAlphaQueue();
ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
@@ -299,7 +559,28 @@ public sealed class EnvCellAlphaDrawSourceTests
AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource(
Renderer.RenderTransparentOrdered,
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();
IGpuFrame frame = _frames.CurrentFrame!;
@@ -323,7 +604,14 @@ public sealed class EnvCellAlphaDrawSourceTests
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;
@@ -339,22 +627,15 @@ public sealed class EnvCellAlphaDrawSourceTests
}
}
- private sealed class ProductionImmediateSink(EnvCellRenderer renderer) :
- IEnvCellImmediateDrawSink
+ private sealed class NullWorldPassSurface : IWorldPassSurface
{
- public int DrawCount { get; private set; }
+ public void PrepareClipFrame() { }
- public bool LastDetailSurfaceActive { get; private set; }
+ public void EnableClipDistances() { }
- public void DrawImmediate(
- uint cellId,
- EnvCellTransparentRoute route,
- bool detailSurfaceActive)
- {
- DrawCount++;
- LastDetailSurfaceActive = detailSurfaceActive;
- renderer.RenderTransparentOrdered([cellId], route, detailSurfaceActive);
- }
+ public void DisableClipDistances() { }
+
+ public void ClearInteriorDepth() => throw new InvalidOperationException();
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
@@ -395,20 +676,4 @@ public sealed class EnvCellAlphaDrawSourceTests
}
}
- private sealed class NoAllocRouteSink
- {
- public void Render(
- IReadOnlyList cells,
- EnvCellTransparentRoute route,
- bool detailSurfaceActive)
- {
- }
-
- public void DrawImmediate(
- uint cellId,
- EnvCellTransparentRoute route,
- bool detailSurfaceActive)
- {
- }
- }
}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
index 30bdf975..d51f1742 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
@@ -74,16 +74,17 @@ public sealed class GpuContractTests
}
[Fact]
- public void BlendModesCoverEveryRetailTranslucencyKind()
+ public void BlendModesCoverEveryRetailRendererState()
{
// WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT
- // surface's TranslucencyKind. Retail has three, and the V0 contract shipped
- // with only two — slice V4c found the gap. Mapping InvAlpha onto
- // StraightAlpha would silently change how every inverse-alpha surface
- // composites, so the contract has to carry all three.
- Assert.Equal(5, Enum.GetValues().Length);
+ // surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a
+ // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while
+ // the detail combine is a separate renderer state. Collapsing any of
+ // these modes changes the corresponding subset's composition.
+ Assert.Equal(6, Enum.GetValues().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues());
+ Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues());
Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues());
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
index a8afbd27..d3692f1e 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
@@ -135,6 +135,14 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant
/// Every recorded call, in submission order.
public IReadOnlyList Calls => _calls;
+ ///
+ /// 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.
+ ///
+ public bool RecordingEnabled { get; set; } = true;
+
/// Backing store for ring allocations, so tests can read what a renderer wrote.
public ReadOnlySpan RingBytes => _ring;
@@ -410,7 +418,8 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant
}
_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));
}
@@ -460,7 +469,8 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial,
"Published host writes require a host-writable storage buffer.",
nameof(buffer));
}
- device.Record(new GpuRecordedHostStorageVisibility(buffer.Name));
+ if (device.RecordingEnabled)
+ device.Record(new GpuRecordedHostStorageVisibility(buffer.Name));
}
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.");
if (pipeline.Description.ViewMask != Pass.ViewMask)
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)
{
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)
{
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)
{
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)
{
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) =>
- device.Record(new GpuRecordedPushConstants(constants));
+ public void SetPushConstants(in GpuPushConstants constants)
+ {
+ if (device.RecordingEnabled)
+ device.Record(new GpuRecordedPushConstants(constants));
+ }
- public void SetViewport(int x, int y, int width, int height) =>
- device.Record(new GpuRecordedViewport(x, y, width, height));
+ public void SetViewport(int x, int y, int width, int height)
+ {
+ if (device.RecordingEnabled)
+ device.Record(new GpuRecordedViewport(x, y, width, height));
+ }
- public void SetScissor(int x, int y, int width, int height) =>
- device.Record(new GpuRecordedScissor(x, y, width, height));
+ public void SetScissor(int x, int y, int width, int 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) =>
- device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance));
+ public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance)
+ {
+ if (device.RecordingEnabled)
+ device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance));
+ }
- public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) =>
- device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance));
+ public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance)
+ {
+ if (device.RecordingEnabled)
+ device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance));
+ }
public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
{
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)
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index 282efc3a..0c644465 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -41,7 +41,13 @@ public sealed class VulkanShaderManifestTests
{
["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df",
["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,
// F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its
// gl_ClipDistance loop, and the gl_PerVertex redeclaration are
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs
index 2d4147d9..099b23a4 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs
@@ -132,11 +132,14 @@ public sealed class VulkanViewportMappingTests
}
[Fact]
- public void AllThreeRetailBlendModesAreRepresentable()
+ public void AllRetailBlendModesAreRepresentable()
{
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha));
+ Assert.Equal(
+ (BlendFactor.One, BlendFactor.OneMinusSrcAlpha),
+ VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.PremultipliedAlpha));
Assert.Equal(
(BlendFactor.SrcAlpha, BlendFactor.One),
VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive));
diff --git a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
index dd50f571..941ca515 100644
--- a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs
@@ -1,3 +1,4 @@
+using System.Collections;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
@@ -9,6 +10,7 @@ using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
+using Chorizite.Core.Render.Enums;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering;
@@ -189,54 +191,110 @@ public sealed class ParticleRendererRouteTests
}
[Fact]
- public void ProductionDispatch_WarmedAppendAndImmediatePathsDoNotAllocate()
+ public void ProductionImmediateMesh_WarmedDrawImmediateParticleSubmissionRhiDoesNotAllocate()
{
- var queue = new RetailAlphaQueue();
- var sink = new NoAllocParticleSink();
- ParticleRenderer.ReserveDeferredParticleDraw reserve = sink.Reserve;
- ParticleRenderer.DrawImmediateParticle immediate = sink.DrawImmediate;
+ using var device = new RecordingGpuDevice(64 * 1024 * 1024);
+ using var manager = new ObjectMeshManager(
+ new VulkanMeshPipelineDevice(device.Retirement),
+ device,
+ new NullPreparedAssetSource(),
+ NullLogger.Instance);
+ ObjectRenderData renderData = Assert.IsType(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++)
- RunPair(queue, sink, reserve, immediate);
+ Type keyType = typeof(ParticleRenderer).GetNestedType(
+ "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();
- for (int i = 0; i < 1_000; i++)
- RunPair(queue, sink, reserve, immediate);
- long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ frames.BeginFrame();
+ renderer.BeginFrame(frameSlot: 0);
+ IGpuFrame frame = frames.CurrentFrame!;
+ 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);
}
- private static void RunPair(
- RetailAlphaQueue queue,
- NoAllocParticleSink sink,
- ParticleRenderer.ReserveDeferredParticleDraw reserve,
- ParticleRenderer.DrawImmediateParticle immediate)
- {
- queue.BeginFrame();
- ParticleRenderer.DeferToRetailAlphaQueue(
- ParticleSubmissionKind.Mesh,
- TranslucencyKind.Opaque,
- 0x80FFFFFFu,
- queue,
- sink,
- reserve,
- immediate,
- Matrix4x4.Identity,
- drawIndex: 0);
- ParticleRenderer.DeferToRetailAlphaQueue(
- ParticleSubmissionKind.Mesh,
- TranslucencyKind.Opaque,
- 0xFFFFFFFFu,
- queue,
- sink,
- reserve,
- immediate,
- Matrix4x4.Identity,
- drawIndex: 1);
- queue.EndFrame();
- }
-
private sealed class RecordingSource : IRetailAlphaDrawSource
{
public int PrepareCount { get; private set; }
@@ -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 tokens)
- {
- }
-
- public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
- {
- }
-
- public void ResetAlphaSubmissions() => _nextToken = 0;
- }
-
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs
index 79c87960..630a4604 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs
@@ -240,9 +240,10 @@ public sealed class RetailAlphaMeshRouterTests
///
/// 's
- /// reachable mapping (opaque/ClipMap are pre-filtered upstream and never
- /// actually call this from a live site, but the function itself must
- /// still map them defensively).
+ /// complete mapping. Ordinary Wb callers pre-filter opaque/ClipMap and
+ /// retain AP-239's earlier subset-mask reconstruction distinction. Mesh
+ /// particles, however, genuinely call this method without that ordinary-
+ /// Wb pre-filter, including for opaque-classified batches.
///
[Theory]
[InlineData(TranslucencyKind.AlphaBlend, RetailAlphaMeshRouter.MaskAlphaFamily)]