From bf23673f3d5acbd55b4953e281aa72be4c28087d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 5 Sep 2026 08:07:51 +0200 Subject: [PATCH] fix(rendering): preserve opaque building coverage under detail MSAA (#473) --- docs/ISSUES.md | 10 +- .../retail-divergence-register.md | 1 + ...-09-01-campaign-overhaul-world-solidity.md | 2 +- .../s5-consumers-material-closeout-packet.md | 63 ++++ .../Wb/WbDrawDispatcher.OrderedStream.cs | 7 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 2 +- .../Walk/OrderPreservingSubmitterTests.cs | 348 ++++++++++++++++-- 7 files changed, 387 insertions(+), 46 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2c1e3031..837363f8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -41,7 +41,8 @@ remain closed. See ## #473 — Owner gate: exterior buildings have missing roof/wall sections -**Status:** IN-PROGRESS — owner visual gate FAIL, 2026-09-05. +**Status:** IN-PROGRESS — bounded code repair complete; owner visual re-gate +pending, 2026-09-05. At clean campaign `02219318a`, Release 0 warnings / 0 errors, the owner reports broken exterior buildings in Holtburg and the cathedral, while @@ -52,7 +53,12 @@ A reachable draw-state defect is now identified (packet section42): detail final alpha is wrongly converted into MSAA sample coverage on opaque Wb exteriors. The owner's 4x/A2C=true log and installed detail alpha24..46/255 exercise that path. Interior EnvCell disables A2C. A bounded pipeline repair -and actual exterior re-gate remain; no claim that all symptoms are fixed yet. +now forces the shared detail-active opaque Wb command onto the non-A2C opaque +pipeline while retaining pure Clip's AP-240 quality-selected arm. Actual +ordered and classic grouped recording tests cover ordinary/atmospheric 1x/4x, +including a mixed ordinary/building instance command, and the old-arm mutation +fails 12/12 discriminators. The actual exterior re-gate remains; no claim that +all symptoms are fixed yet. Evidence: `research/2026-09-01-overhaul/owner-gate-20260905-holtburg-fail.png`; manual session `logs/owner-gate-20260905-02219318a/`, PID18836, verified diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c2a9636d..38c0cb07 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -278,6 +278,7 @@ builder, reducing the current active total to **160**. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| ~~AP-245~~ | **FILED AND CODE-RETIRED 2026-09-05 by #473 / Campaign OVERHAUL §42.** S5-c4's shared detail-active Wb selector reused the quality-selected opaque pipeline for `RetailSetSurfaceBlend.Opaque`. With 4x MSAA and A2C enabled, the shader's retail final detail alpha `X = a*qA*qA` therefore became sample coverage even though retail's ordinary opaque SetSurface state is ONE/ZERO without alpha test. The selector now uses the non-A2C `pipelines.Opaque` for every detail-bearing opaque command; pure Clip retains AP-240's `opaquePipeline`. A classic command may contain both ordinary and building-detail instances because detail category is per instance rather than part of `GroupKey`; such a command is correctly non-A2C as a whole while its shader still filters detail per instance. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`PipelineForMaterial`); `tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs` | The correction is one shared material-selection arm reached by the actual ordered and classic grouped submitters. Recording tests cover ordinary/atmospheric 1x/4x descriptors, draw order/count, depth and detail pushes; detail unavailable/disabled and quality A2C=false stay unchanged. | None for the retired code divergence. The owner exterior gate remains FAIL until the repaired exact binary passes the required real re-gate; this row does not claim GPU or owner acceptance. | `D3DPolyRender::SetSurface @0x0059C4D0` opaque ONE/ZERO arm; Campaign OVERHAUL §42 | | AP-244 | **Filed 2026-09-05 at Campaign OVERHAUL S5-c4 fix round 1.** The new exact resolved SetSurface state is deliberately selected only when a Wb/EnvCell command has active building detail, preserving §23's bounded detail-off behavior. With detail off, Wb still collapses raw Additive and InvAlpha+Additive through `TranslucencyKind.Additive` to `SRCALPHA,ONE`; EnvCell additionally selects raw `IsAdditive` before the late `Translucent\|ClipMap` override and has no detail-off inverse pipeline. Both shader families retain the existing non-detail 0.05 discard rather than every combined ClipMap's palette/DDS reference, and fixed-function fog remains enabled for raw-Additive detail-off rows. AP-240 separately covers Wb pure-Clip placement/A2C. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`PipelineForBlend`, detail gates); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (`ResolveBatchGroupIndex`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (detail-active exact selection versus retained range buckets); `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag` | Fix round 1 was expressly bounded to detail-active one-pass material truth. The immutable state is already present end-to-end, so a later dedicated detail-off correction can select it without DAT lookup or new ownership, but must re-gate FIFO/routing/order and visual behavior together. | Detail-off raw Additive/inverse-additive brightness, inverse EnvCell compositing, combined ClipMap cutout threshold, late Translucent override, and raw-Additive fog can differ from retail. | `D3DPolyRender::SetSurface @0x0059C4D0` lines 425083–425303; Campaign OVERHAUL S5-c4 §23.2 | | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 52c80238..defaac71 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -696,7 +696,7 @@ Update immediately when a slice changes state. Chat is not the ledger. | S5-#470 | **LANDED + REVIEW-CLOSED 2026-09-05; LEAD GRAPHICAL A/B PROVISIONAL PASS.** Campaign implementation stack `15a796c3a` -> `7506e5f14` -> `b333edb4f`; route `19b44e5e3`; reviewed scratch `51f974da4` -> `2cad9c84` -> `98c004aa7`; packet §§27–30. | G4 UNPASSED | Retail/behavior pass 2/10 and production pass 4/10 closed the two evidence-only findings. Fresh campaign Release 0W/0E and focused 48/48; exact scratch hermetic 16,976/16,976 and canonical InstalledDat 386/10/1, manifests 30/30. Pinned gate `logs/selfgate-20260905-032132-s5-470-pinned-shadow-ab-r1`: retail/off -> High -> retail/off, 3/3 PNGs, exit 0, graceful, no client; visual PASS provisional. The stale recipe-8 pre-route launch is excluded and recorded in §30. | | S5-#469 | **LANDED + REVIEW-CLOSED 2026-09-05; LEAD GRAPHICAL TRANSITION PROVISIONAL PASS.** Contract `809887524`; reviewed scratch `c09b6cf0f`; campaign implementation `94ddde69a`; route `62efc72cb`; packet §§31–32. | G4 UNPASSED | Retail/deviation pass 1/10 and production pass 2/10 both PASS with no finding. The two atmospheric vertex receivers keep retail's authored unnormalized `uLights` direction across every shadow gate; celestial direction remains for opt-in shadow/volumetric projection; IA-24 corrected in the same commit. Exact scratch: Release 0W/0E, focused 126/126, Vulkan 2/2, hermetic 16,983/16,983, InstalledDat 386/10/1. Fresh campaign Release 0W/0E, focused 82/82, Vulkan 2/2. Gate `logs/selfgate-20260905-040449-s5-469-lighting-transition-r1`: five PNGs, active 2,500-caster/four-cascade High rows, no near-black relight, exit 0/graceful, no client; visual PASS provisional. | | S5-c5 | **CLOSED + LANDED 2026-09-05; LEAD GRAPHICAL SMOKE PROVISIONAL PASS.** Contract `b77989c32`; campaign `bf53e2ad6` -> `e625dc4e6` -> `1b7ee4e58`; reviewed scratch tip `158656f0d`; packet §§33–35. | G4 UNPASSED | Retail/deletion pass 1/10 PASS. Production pass 2/10 found one static-field hole in the owner guard; fix round 1 reproduced both static-owner mutations, and production pass 3/10 PASS. Exact scratch hermetic 16,921/16,921, canonical InstalledDat 368/9/1, manifests 30/30. Fresh campaign Release 0W/0E, App 146/146, Core 8/8. A stale recipe-8 preflight is excluded; a non-destructive recipe-10 bake produced 2,237,865 keys with zero failures. Corrected gate `logs/selfgate-20260905-052130-s5-c5-landed-v10`: five PNGs, exit 0/graceful, no fatal/deleted-prefix match, no client left; geometry matches S5-c4, visual PASS provisional. | -| S5 | **OWNER VISUAL GATE FAIL — #473 exterior roof/wall sections missing (2026-09-05).** c1–c5 plus #470/#469 landed; prior self-PASS is not owner acceptance. C1 offline evidence complete; C1c test correction frozen pending review. | G4 | Packet §§7 and 12–42 bind. Owner's clean02219318a Release/recipe10 gate failed Holtburg/cathedral exteriors; Facility interiors appear correct. §42 contracts the identified detail-active opaque A2C pipeline repair. The 4x/A2C=true owner path converts low detail alpha into lost coverage; a tested repair and correctly framed exterior re-gate remain. Prior screenshots did not cover complete exteriors. After repair/reviews/re-gate: C1c review/landing, real geometry/membership hash, full-lane closure, C2 and full owner G4 remain. Never merge main before G4. | +| S5 | **OWNER VISUAL GATE FAIL — #473 code-repaired and review-closed; exterior re-gate pending (2026-09-05).** c1–c5 plus #470/#469 landed; prior self-PASS is not owner acceptance. C1 offline evidence complete; C1c test correction frozen pending review. | G4 | Packet §§7 and 12–42 bind. §42 repairs detail-active opaque A2C coverage loss at the owner's 4x/A2C=true settings. Both independent review lenses PASS; 45 focused/46 register-inclusive/158 adjacent tests pass; restored old-arm mutation fails12/12. All52 affected base/LOD prepared payloads match fresh extraction. Correctly framed full Holtburg/cathedral exterior PNG re-gate remains; prior images were inadequate. Then C1c review/landing, real geometry/membership hash, full-lane closure, C2 and full owner G4 remain. Never merge main before G4. | --- diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md index a1ee0613..fce9a070 100644 --- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md +++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md @@ -4010,3 +4010,66 @@ PASS/FAIL; close only the lead's own session gracefully. Lead PASS remains provisional until the owner re-gates. Keep #473 open if rendered sections remain missing, even if the narrow tests pass. No merge main; C1c and all remaining closeout/G4 obligations resume only after this failure is handled. + +### 42.4 Implementation return — uncommitted, pending review and real re-gate + +At base `d35ed1aec`, the bounded production repair changes only +`PipelineForMaterial`'s detail-active Opaque arm from `opaquePipeline` to +`pipelines.Opaque`; the pure Clip arm and every shader, prepared-data, +geometry, LOD, queue and recipe path are unchanged. The actual ordered and +classic grouped recording paths now cover ordinary and atmospheric pipeline +sets at 1x and 4x. They pin exact non-A2C/A2C descriptors, opaque depth state, +draw order/count and detail push fields. The classic proof also includes one +real mixed command with detail categories `[0,1]`: because category is not in +`GroupKey`, the whole detail-bearing command uses non-A2C while the shader's +per-instance category still controls detail sampling. Detail unavailable, +detail disabled and quality A2C=false remain on their pre-repair selections. + +The old-arm mutation (`Opaque => opaquePipeline`) failed all 12 selected +ordered/classic cases. The first ordered failure expected +`wb-mesh-opaque` at pipeline transcript position 2 but received +`wb-mesh-opaque-a2c`; the first classic failure expected +`wb-mesh-atmospheric-opaque-1x` at position 1 but received +`wb-mesh-atmospheric-opaque-a2c-1x`. The mixed-command discriminator likewise +expected non-A2C and received A2C. The one-line repair was restored and the +complete `OrderPreservingSubmitterTests` class passed 45/45 in Release. +`dotnet build AcDream.slnx -c Release --no-restore` then completed with zero +warnings and zero errors. A final no-build run of that class plus the existing +register/count guard passed 46/46. `git diff --check` is clean. No client was +launched; JSON preflights before each build/test found no AcDream, retail or +debugger process and ACE UDP9000 PID13340. The owner gate remains FAIL and the +required exact-binary exterior re-gate is pending sequential review and lead +execution. + +### 42.5 Lead review closure — graphical acceptance still pending + +Sequential independent OpenAI review round1 passed both lenses without +findings: Rawls checked named SetSurface, paired PE bytes, installed detail +alpha, callers and retained material behavior; Wegener checked production +reachability, state/order/ownership, actual dispatcher tests and gate honesty. +The lead independently read the complete six-file diff, verified the same +retail byte ranges and TRX mutation failures, and ran the adjacent existing +material suite: 158/158 passed, zero skipped. Exact result files are under +`tests/AcDream.App.Tests/TestResults/`: `s5-473-old-arm-mutation.trx` (0/12), +`s5-473-focused-restored-final.trx` (45/45), +`s5-473-focused-and-register-final.trx` (46/46), and +`s5-473-lead-adjacent-material.trx` (158/158). + +The tested candidate App.dll and the test directory's App.dll were identical: +6F8890AA5E7FA16641EB5E9AB8CEDAC748E9CD5084853964EDCB5512EBE48E71. +This is pre-commit candidate identity, not the subsequent graphical binary. +Lead independently expanded the installed A9B4/F418 base/nonzero-LOD union +and compared fresh canonical extraction against the actual recipe10 package: +all52 complete ObjectMeshDataSerializer payloads match, no missing or corrupt +read and no differing payload. This rules out that package/extractor mismatch +for these meshes, not GPU upload or final coverage. It is not a replacement +for C1's real placed geometry/membership witness. + +No review finding or implementation fix round was needed. Mixed classic +commands use one non-A2C opaque pipeline for all instances if any has detail; +only wholly unarmed commands/global detail-off preserve quality-selected A2C. +The owner expressly authorized closing/testing. Their original process had +already exited gracefully without lead input. Lead may now commit, rebuild +Release with no client present and execute the correctly framed exterior gate. +Owner FAIL remains authoritative until that rendered result is inspected; +there is no G4 or physical-GPU PASS in this review closure. diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs index c991cb2c..b73272f1 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs @@ -58,9 +58,10 @@ public sealed unsafe partial class WbDrawDispatcher /// /// The four pipeline buckets a translucency kind resolves to, independent /// of any live instance. - /// selects between Opaque and OpaqueAlphaToCoverage uniformly - /// for the whole submission — it never varies per command — so merge-run - /// legality only needs to know WHICH bucket a command falls in, not which + /// selects between Opaque and OpaqueAlphaToCoverage for the + /// default opaque bucket. A solo detail-category command may then select + /// its exact material pipeline (including non-A2C Opaque), so merge-run + /// legality needs the bucket plus the existing detail boundary, not the /// concrete that resolves to. That is what lets /// stay pure CPU logic, testable /// without a live GPU device — the same separation diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index f5294586..abc2850a 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -858,7 +858,7 @@ public sealed unsafe partial class WbDrawDispatcher RetailSetSurfaceMaterialState material, IGpuPipeline opaquePipeline) => material.Blend switch { - RetailSetSurfaceBlend.Opaque => opaquePipeline, + RetailSetSurfaceBlend.Opaque => pipelines.Opaque, RetailSetSurfaceBlend.StraightAlpha => material.AlphaTestEnabled ? pipelines.AlphaBlendDepthWrite : pipelines.AlphaBlend, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs index 80df2d2b..e7a38f04 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Numerics; +using System.Reflection; using System.Runtime.InteropServices; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; @@ -288,58 +289,201 @@ public sealed class OrderPreservingSubmitterTests } [Theory] - [InlineData(false, 200f / 255f)] - [InlineData(true, 100f / 255f)] - public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassInPlace( - bool paletted, - float expectedReference) + [InlineData(false, 1)] + [InlineData(false, 4)] + [InlineData(true, 1)] + [InlineData(true, 4)] + public void PrepareThenDraw_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassWithoutOpaqueCoverage( + bool atmospheric, + int sampleCount) { - using var fx = new DispatcherFixture(detailAvailable: true, detailEnabled: true); + foreach (bool paletted in new[] { false, true }) + { + using var fx = new DispatcherFixture( + detailAvailable: true, + detailEnabled: true, + atmospheric: atmospheric, + sampleCount: sampleCount); + using DrawScope draw = fx.BeginDraw(); + + OrderedDrawStream stream = StreamOf( + MakeCommand(0), + MakeCommand(1, detailCategory: 1), + MakeCommand( + 2, + translucency: TranslucencyKind.ClipMap, + detailCategory: 1, + materialState: RetailSetSurfaceMaterialState.Resolve( + SurfaceType.Base1ClipMap, + texturePresent: true, + textureHasPalette: paletted)), + MakeCommand(3)); + + PrepareAndDrawWhole(fx.Dispatcher, draw, stream); + + string prefix = atmospheric ? "wb-mesh-atmospheric" : "wb-mesh"; + string suffix = sampleCount == 1 ? "-1x" : string.Empty; + string opaque = $"{prefix}-opaque{suffix}"; + string opaqueA2c = $"{prefix}-opaque-a2c{suffix}"; + List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); + Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], + runs.Select(run => (run.Start, run.Count)).ToList()); + Assert.Equal((0u, 0f, 0f), DetailFields(runs[0].Constants)); + Assert.Equal((77u, 3.5f, 0f), DetailFields(runs[1].Constants)); + Assert.Equal( + (77u, 3.5f, paletted ? 100f / 255f : 200f / 255f), + DetailFields(runs[2].Constants)); + Assert.Equal((0u, 0f, 0f), DetailFields(runs[3].Constants)); + Assert.Equal( + [opaque, opaqueA2c, opaque, opaqueA2c, opaqueA2c], + fx.Device.Calls.OfType() + .Select(call => call.PipelineName) + .ToArray()); + Assert.Equal(4, fx.Device.Calls.OfType().Count()); + + AssertOpaquePipeline(fx.Device, opaque, atmospheric, sampleCount, alphaToCoverage: false); + AssertOpaquePipeline(fx.Device, opaqueA2c, atmospheric, sampleCount, alphaToCoverage: true); + } + } + + [Theory] + [InlineData(false, 1)] + [InlineData(false, 4)] + [InlineData(true, 1)] + [InlineData(true, 4)] + public void ClassicGrouped_OrdinaryBuildingClipBuildingOrdinary_ArmsOnePassWithoutOpaqueCoverage( + bool atmospheric, + int sampleCount) + { + using var fx = new DispatcherFixture( + detailAvailable: true, + detailEnabled: true, + atmospheric: atmospheric, + sampleCount: sampleCount); using DrawScope draw = fx.BeginDraw(); - OrderedDrawStream stream = StreamOf( - MakeCommand(0), - MakeCommand(1, detailCategory: 1), - MakeCommand( + ExecuteClassicGroups( + fx, + MakeClassicGroup(0, detailCategory: 0), + MakeClassicGroup(1, detailCategory: 1), + MakeClassicGroup( 2, - translucency: TranslucencyKind.ClipMap, detailCategory: 1, + translucency: TranslucencyKind.ClipMap, materialState: RetailSetSurfaceMaterialState.Resolve( SurfaceType.Base1ClipMap, texturePresent: true, - textureHasPalette: paletted)), - MakeCommand(3)); + textureHasPalette: false)), + MakeClassicGroup(3, detailCategory: 0)); + + string prefix = atmospheric ? "wb-mesh-atmospheric" : "wb-mesh"; + string suffix = sampleCount == 1 ? "-1x" : string.Empty; + string opaque = $"{prefix}-opaque{suffix}"; + string opaqueA2c = $"{prefix}-opaque-a2c{suffix}"; + var transcript = DecodePipelineRuns(fx.Device); + Assert.Equal([opaqueA2c, opaque, opaqueA2c, opaqueA2c], + transcript.Select(entry => entry.Pipeline)); + Assert.All(transcript, entry => Assert.Equal(1u, entry.Draw.DrawCount)); + Assert.Equal((0u, 0f, 0f), DetailFields(transcript[0].Constants)); + Assert.Equal((77u, 3.5f, 0f), DetailFields(transcript[1].Constants)); + Assert.Equal((77u, 3.5f, 200f / 255f), DetailFields(transcript[2].Constants)); + Assert.Equal((0u, 0f, 0f), DetailFields(transcript[3].Constants)); + AssertOpaquePipeline(fx.Device, opaque, atmospheric, sampleCount, alphaToCoverage: false); + AssertOpaquePipeline(fx.Device, opaqueA2c, atmospheric, sampleCount, alphaToCoverage: true); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(false, 4)] + [InlineData(true, 1)] + [InlineData(true, 4)] + public void ClassicGrouped_MixedOrdinaryAndBuildingInstances_UsesNonCoverageForWholeCommand( + bool atmospheric, + int sampleCount) + { + using var fx = new DispatcherFixture( + detailAvailable: true, + detailEnabled: true, + atmospheric: atmospheric, + sampleCount: sampleCount); + using DrawScope draw = fx.BeginDraw(); + WbDrawDispatcher.InstanceGroup mixed = MakeClassicGroup(0, detailCategory: 0); + AppendClassicInstance(mixed, index: 1, detailCategory: 1); + + ExecuteClassicGroups(fx, mixed); + + string prefix = atmospheric ? "wb-mesh-atmospheric" : "wb-mesh"; + string suffix = sampleCount == 1 ? "-1x" : string.Empty; + var entry = Assert.Single(DecodePipelineRuns(fx.Device)); + Assert.Equal($"{prefix}-opaque{suffix}", entry.Pipeline); + Assert.Equal(1u, entry.Draw.DrawCount); + Assert.Equal((77u, 3.5f, 0f), DetailFields(entry.Constants)); + Assert.Equal([0u, 1u], mixed.DetailCategories); + Assert.Equal(2, mixed.Matrices.Count); + } + + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void PrepareThenDraw_DetailUnavailableDisabledOrQualityA2cFalsePreservesSelection( + bool detailAvailable, + bool detailEnabled, + bool alphaToCoverage) + { + using var fx = new DispatcherFixture( + detailAvailable: detailAvailable, + detailEnabled: detailEnabled, + sampleCount: 4, + alphaToCoverage: alphaToCoverage); + using DrawScope draw = fx.BeginDraw(); + OrderedDrawStream stream = StreamOf( + MakeCommand(0), + MakeCommand(1, detailCategory: 1)); PrepareAndDrawWhole(fx.Dispatcher, draw, stream); - List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); - Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], - runs.Select(run => (run.Start, run.Count)).ToList()); - Assert.Equal((0u, 0f), (runs[0].Constants.TextureIndexA, runs[0].Constants.ParamA)); - Assert.Equal((77u, 3.5f), (runs[1].Constants.TextureIndexA, runs[1].Constants.ParamA)); - Assert.Equal((77u, 3.5f), (runs[2].Constants.TextureIndexA, runs[2].Constants.ParamA)); - Assert.Equal((0u, 0f), (runs[3].Constants.TextureIndexA, runs[3].Constants.ParamA)); + bool detailActive = detailAvailable && detailEnabled; + string selected = alphaToCoverage ? "wb-mesh-opaque-a2c" : "wb-mesh-opaque"; Assert.Equal( - [ - "wb-mesh-opaque-1x", - "wb-mesh-opaque-a2c-1x", - "wb-mesh-opaque-a2c-1x", - "wb-mesh-opaque-a2c-1x", - "wb-mesh-opaque-a2c-1x", - ], + ["wb-mesh-opaque", selected, detailActive ? "wb-mesh-opaque" : selected], fx.Device.Calls.OfType() - .Select(call => call.PipelineName) - .ToArray()); - Assert.Equal(4, fx.Device.Calls.OfType().Count()); - Assert.Equal(expectedReference, runs[2].Constants.ParamB); - GpuPipelineDescription a2c = fx.Device.CreatedPipelines - .Single(pipeline => pipeline.Description.Name == "wb-mesh-opaque-a2c-1x") - .Description; - Assert.Equal(GpuBlendMode.None, a2c.Blend); - Assert.True(a2c.AlphaToCoverage); - Assert.True(a2c.Depth.Test); - Assert.True(a2c.Depth.Write); - Assert.Equal(WorldDepthContract.WorldCompare, a2c.Depth.Compare); + .Select(call => call.PipelineName)); + List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device); + Assert.Equal([(0, 1), (1, 1)], runs.Select(run => (run.Start, run.Count))); + Assert.Equal((0u, 0f, 0f), DetailFields(runs[0].Constants)); + Assert.Equal( + detailActive ? (77u, 3.5f, 0f) : (0u, 0f, 0f), + DetailFields(runs[1].Constants)); + } + + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void ClassicGrouped_DetailUnavailableDisabledOrQualityA2cFalsePreservesSelection( + bool detailAvailable, + bool detailEnabled, + bool alphaToCoverage) + { + using var fx = new DispatcherFixture( + detailAvailable: detailAvailable, + detailEnabled: detailEnabled, + sampleCount: 4, + alphaToCoverage: alphaToCoverage); + using DrawScope draw = fx.BeginDraw(); + + ExecuteClassicGroups(fx, MakeClassicGroup(0, detailCategory: 1)); + + bool detailActive = detailAvailable && detailEnabled; + var entry = Assert.Single(DecodePipelineRuns(fx.Device)); + Assert.Equal(alphaToCoverage && !detailActive + ? "wb-mesh-opaque-a2c" + : "wb-mesh-opaque", entry.Pipeline); + Assert.Equal(1u, entry.Draw.DrawCount); + Assert.Equal( + detailActive ? (77u, 3.5f, 0f) : (0u, 0f, 0f), + DetailFields(entry.Constants)); } [Fact] @@ -832,6 +976,130 @@ public sealed class OrderPreservingSubmitterTests private static List<(int Start, int Count)> DecodeDrawRanges(RecordingGpuDevice device) => [.. DecodeRuns(device).Select(r => (r.Start, r.Count))]; + private static (uint TextureIndexA, float ParamA, float ParamB) DetailFields( + GpuPushConstants constants) => + (constants.TextureIndexA, constants.ParamA, constants.ParamB); + + private static void AssertOpaquePipeline( + RecordingGpuDevice device, + string name, + bool atmospheric, + int sampleCount, + bool alphaToCoverage) + { + GpuPipelineDescription description = device.CreatedPipelines + .Single(pipeline => pipeline.Description.Name == name) + .Description; + Assert.Equal(atmospheric ? "mesh_atmospheric" : "mesh_modern", description.Shaders.Name); + Assert.Equal(GpuBlendMode.None, description.Blend); + Assert.Equal(alphaToCoverage, description.AlphaToCoverage); + Assert.Equal(sampleCount, description.SampleCount); + Assert.True(description.Depth.Test); + Assert.True(description.Depth.Write); + Assert.Equal(WorldDepthContract.WorldCompare, description.Depth.Compare); + } + + private static WbDrawDispatcher.InstanceGroup MakeClassicGroup( + int index, + uint detailCategory, + TranslucencyKind translucency = TranslucencyKind.Opaque, + RetailSetSurfaceMaterialState? materialState = null) + { + var group = new WbDrawDispatcher.InstanceGroup + { + FirstIndex = (uint)index * 3, + BaseVertex = index * 4, + IndexCount = 3, + TextureSlot = new GpuTextureSlot((uint)index), + TextureLayer = 0, + Translucency = translucency, + MaterialState = materialState ?? RetailSetSurfaceMaterialState.Opaque, + SurfaceOpacity = 1f, + CullMode = CullMode.CounterClockwise, + FoliageFlags = 0, + }; + group.Matrices.Add(Matrix4x4.CreateTranslation(index, index * 2, index * 3)); + group.SubmissionOrders.Add(index); + group.Slots.Add(0); + group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); + group.IndoorFlags.Add(0); + group.DetailCategories.Add(detailCategory); + group.Opacities.Add(1f); + group.SelectionLighting.Add(Vector2.Zero); + return group; + } + + private static void AppendClassicInstance( + WbDrawDispatcher.InstanceGroup group, + int index, + uint detailCategory) + { + group.Matrices.Add(Matrix4x4.CreateTranslation(index, index * 2, index * 3)); + group.SubmissionOrders.Add(index); + group.Slots.Add(0); + group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); + group.IndoorFlags.Add(0); + group.DetailCategories.Add(detailCategory); + group.Opacities.Add(1f); + group.SelectionLighting.Add(Vector2.Zero); + } + + private static void ExecuteClassicGroups( + DispatcherFixture fixture, + params WbDrawDispatcher.InstanceGroup[] groups) + { + fixture.Dispatcher.BeginFrame(frameSlot: 0); + MethodInfo execute = typeof(WbDrawDispatcher).GetMethod( + "ExecuteClassifiedGroups", + BindingFlags.Instance | BindingFlags.NonPublic)!; + execute.Invoke( + fixture.Dispatcher, + [ + Matrix4x4.Identity, + Vector3.Zero, + 0u, + groups, + WbDrawDispatcher.EntitySet.All, + groups.Length, + groups.Length, + false, + false, + ]); + } + + private static List<( + string Pipeline, + GpuPushConstants Constants, + GpuRecordedMultiDrawIndirect Draw)> DecodePipelineRuns(RecordingGpuDevice device) + { + var transcript = new List<( + string Pipeline, + GpuPushConstants Constants, + GpuRecordedMultiDrawIndirect Draw)>(); + IReadOnlyList calls = device.Calls; + for (int i = 0; i < calls.Count; i++) + { + if (calls[i] is not GpuRecordedMultiDrawIndirect draw) + continue; + + string? pipeline = null; + GpuPushConstants? constants = null; + for (int prior = i - 1; prior >= 0 && (pipeline is null || constants is null); prior--) + { + if (pipeline is null && calls[prior] is GpuRecordedPipelineBind bind) + pipeline = bind.PipelineName; + if (constants is null && calls[prior] is GpuRecordedPushConstants push) + constants = push.Constants; + } + + Assert.NotNull(pipeline); + Assert.NotNull(constants); + transcript.Add((pipeline!, constants!.Value, draw)); + } + + return transcript; + } + private static List<(GpuPushConstants Constants, int Start, int Count)> DecodeRuns( RecordingGpuDevice device) { @@ -892,6 +1160,7 @@ public sealed class OrderPreservingSubmitterTests bool atmospheric = false, bool receiverBindingAvailable = true, int sampleCount = 1, + bool alphaToCoverage = true, RecordingGpuDevice? device = null) { Device = device ?? new RecordingGpuDevice(); @@ -923,6 +1192,7 @@ public sealed class OrderPreservingSubmitterTests new GpuTextureSlot(77), 3.5f, 0x05000001, 0x08000001, 16, 16) : default, buildingDetailEnabled: () => detailEnabled); + Dispatcher.AlphaToCoverage = alphaToCoverage; if (atmospheric) { var source = new BindableAtmosphericSource(receiverBindingAvailable);