diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 38ae2e46..328b6ea8 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` |
| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 |
| IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` |
-| IA-24 | **Filed 2026-08-22, Campaign AR; amended 2026-09-04, Campaign OVERHAUL S5-c2 and its graphical closeout.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Its active terrain/object projection borrows retail's **prior successfully completed** landscape visibility by exact set reference, then selects ordinary objects by S2's retained retail CELLARRAY intersection and building shells by their outdoor placement `EffectCellId`; missing membership and no completed view fail closed, with no resident/frustum fallback. **While shadows render, the pack's receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) also take the outdoor directional LIGHTING direction from that same celestial source instead of retail's authored `uLights[0]` direction, so the lit term agrees with the shadow direction; whenever the shadow gate is closed (night, user strength 0, portal cover, indoor) the flag bit is clear and both shaders fall back to the plain pipeline's authored-light expression (Campaign VM VM6 round 5, `754d59d9`). The switch is binary even when the effective shadow strength is small: S5-c2's exact-parent/candidate graphical A/B (§16) reproduces a conspicuous whole-world darkening across pack re-enable. Issue #469 keeps that pre-existing gate discontinuity open before G4; recording it here does not classify it as an S5-c2 regression.** Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` and OVERHAUL packet §§14,16 | This is the user-requested headline graphics enhancement and is strictly opt-in. Borrowing the prior completed retail landscape product and exact S2 membership narrows pack-on work without inventing another visibility owner. The retail path remains the default and authoritative fallback; pack-off does not build, select, upload, or draw shadow work and does not change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail and deliberately trails camera visibility by one successfully completed world view. A wrong CELLARRAY/EffectCell identity, stale prior-view transaction, celestial transform, or unresolved #469 gate transition would visibly add, omit, misalign, or abruptly relight the world; pack-off output changing would violate the campaign's primary safety contract. | Landscape input: `LScape::draw_check_blocks @0x00505F80`, `LScape::landcell_check @0x005050A0`, `CLandCell::IsInView @0x00532CB0`; celestial: `SkyDesc::GetLighting @0x00500A80`, `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research notes |
+| IA-24 | **Filed 2026-08-22, Campaign AR; amended 2026-09-04, Campaign OVERHAUL S5-c2 and its graphical closeout; amended 2026-09-05, Campaign OVERHAUL S5 issue #469.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Its active terrain/object projection borrows retail's **prior successfully completed** landscape visibility by exact set reference, then selects ordinary objects by S2's retained retail CELLARRAY intersection and building shells by their outdoor placement `EffectCellId`; missing membership and no completed view fail closed, with no resident/frustum fallback. **The authored, unnormalized `uLights` direction remains the sole base-light direction in both receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) in every shadow-gate state, exactly matching their plain counterparts. The selected celestial source drives only the opt-in shadow and volumetric projection. Its direction may therefore visibly differ from the authored base-light angle; that light/shadow-angle separation is the deliberate cost of preserving retail lighting while eliminating the former binary whole-world relight across pack re-enable.** Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` and OVERHAUL packet §§14,16,31 | This is the user-requested headline graphics enhancement and is strictly opt-in. Borrowing the prior completed retail landscape product and exact S2 membership narrows pack-on work without inventing another visibility owner. The retail path remains the default and authoritative fallback; pack-off does not build, select, upload, or draw shadow work and does not change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail and deliberately trails camera visibility by one successfully completed world view. A wrong CELLARRAY/EffectCell identity, stale prior-view transaction, celestial transform, or authored-light/celestial projection separation would visibly add, omit, misalign, or redirect shadows; any whole-world base-light jump across pack gating or any pack-off output change would violate the campaign's primary safety contract. | Landscape input: `LScape::draw_check_blocks @0x00505F80`, `LScape::landcell_check @0x005050A0`, `CLandCell::IsInView @0x00532CB0`; celestial: `SkyDesc::GetLighting @0x00500A80`, `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research notes |
| IA-25 | **Filed 2026-08-22, Campaign VM VM6.** An explicitly selected atmospheric render pack sways procedural-scenery foliage (trees/bushes — entity ids in the `0x8XXYYIII` `ProceduralSceneryIdAllocator` namespace) in `mesh_atmospheric.vert` and the four `directional_shadow_world_*` caster vertex shaders, driven by a weather-table lean/branch/flutter vertex displacement (`foliage_wind.glsl`, `FoliageWindModel` CPU mirror) whose mean/gust strength is looked up per DAT-classified `AcDream.Core.World.WeatherKind` (Clear/Overcast/Rain/Snow/Storm — the same classification `WeatherState.cs` already derives from the active day group's authored name, not the day group's raw index, which carries no weather meaning by itself) and eases toward its target over `WeatherSystem.TransitionSeconds` (10 s) so a weather change never snaps. Retail's fixed-function renderer applies no per-vertex wind displacement to any scenery mesh — Dereth's trees are static geometry. Wind direction (`wind-direction-degrees`, default 225°) is a plain pack default: there is no authored retail wind direction to read (no wind data exists in retail at all). Render-only: `WorldPicker` picks the undisplaced mesh, so a swaying leaf can be up to `lean + branch` metres from its pick volume at the moment of a click; foliage subsets are cosmetic scenery, not interactable in retail either. | `src/AcDream.App/Rendering/Shaders/foliage_wind.glsl`; `src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs`; `src/AcDream.App/Rendering/Packs/FoliageWindModel.cs`; `src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs` (`ResolveFoliageWind`); `src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs` (wind settings + `FoliageWindByWeather`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`) never reads `BatchData.flags` bits 1/2 and is pixel-identical with the pack off. The classification never touches Runtime/Core physics — the collision BSP is the trunk, and picking against the undisplaced mesh has no gameplay consequence since foliage is not interactable. | Pack-on output intentionally differs from retail (moving foliage where retail has none). A wrong classification bit would sway a non-foliage object or leave a real tree still; a caster/receiver clock or amplitude mismatch would visibly misalign a leaf's shadow from the leaf itself. Pack-off output changing would violate the campaign's primary safety contract. | None — retail applies no vertex wind displacement to any geometry; `ProceduralSceneryIdAllocator` (top-nibble-0x8 entity-id namespace, existing acdream mechanism, not retail) |
---
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
index 43f426e6..c45f9fda 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
@@ -247,31 +247,19 @@ vec3 accumulateAmbientLocalLights(
// get no sun even in windowed buildings where the player's frame is not sun-killed.
if (uLightingMode == 0) {
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
- // Campaign VM VM6 review fix round 5 (F1 BLOCKER): when the
- // shadow block's flag bit is clear (a gated-off frame — see
- // DirectionalSunShadowRenderer.PublishDisabledReceiverBinding),
- // uShadowLightDirectionAndSource carries only a (0,0,1) NaN
- // guard, NOT a real light direction — using it here regardless
- // lit every gated-off frame (every night, sun-shadow-strength
- // 0, indoors, portal cover) from straight overhead. Fall back
- // to the EXACT plain mesh_modern.vert expression
- // (-uLights[i].dirAndRange.xyz, unnormalized — matched
- // bit-for-bit) so a gated-off frame matches the plain
- // pipeline to within float summation-order rounding
- // (review fix round 6, N2: the atmospheric fragment's split
- // ambient+point vs directional accumulation reassociates the
- // sum relative to the plain pipeline's single varying —
- // measured mean |Δ| 0.007 on the offline scene, not bit-
- // identical). Hoisted out of the loop: a uniform branch,
- // evaluated once per vertex regardless of light count.
- bool shadowGatedOff = (uShadowTextureAndFlags.w & 1u) == 0u;
+ // Campaign OVERHAUL S5 #469: retail owns one authored outdoor
+ // directional-light channel (SkyDesc::GetLighting ->
+ // LScape::set_landscape_lighting -> Render::world_lights.sunlight).
+ // Keep the EXACT plain mesh_modern.vert direction in every shadow
+ // gate state: negate uLights[].dirAndRange.xyz and preserve its
+ // authored, unnormalized magnitude. The selected celestial source
+ // remains pack-only projection input in the fragment receiver and
+ // volumetric shaders; it never replaces base vertex lighting.
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
- vec3 Ldir = shadowGatedOff
- ? -uLights[i].dirAndRange.xyz
- : normalize(uShadowLightDirectionAndSource.xyz);
+ vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
directionalLit += uLights[i].colorAndIntensity.xyz
* uLights[i].colorAndIntensity.w * ndl;
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv
index 3dc26aa0..4600c0bf 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.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 bd61c558..e8e3ddca 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -215,7 +215,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "bf470ee19d0ea5fd36f6ac96e307a71845e50d8872263cfa15d1c36fc6424732",
+ "sourceSha256": "b1e8e52b0b3dedec6c7dfd29ce3e70deb44d6b18197d6bd312e1a34e543f8337",
"compiled": true
},
{
@@ -311,7 +311,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "42d1dbd0dbd950862e8672f79a3f429f771bae55fd2b5721c3e551a0ea9563b6",
+ "sourceSha256": "cd300c58b15401fa1b409b1df333b13c8aca7ff26c02615dd4811a39ffd79460",
"compiled": true
},
{
diff --git a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv
index 0cc9e4d3..309f9f4a 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
index fe8021f1..9dc38cb2 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
+++ b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
@@ -142,20 +142,12 @@ void main() {
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
- // Campaign VM VM6 review fix round 5 (F1 BLOCKER): when the shadow
- // block's flag bit is clear (a gated-off frame), uShadowLightDirectionAndSource
- // carries only a (0,0,1) NaN guard, NOT a real light direction. Fall
- // back to the EXACT plain terrain_modern.vert expression
- // (-uLights[0].dirAndRange.xyz, unnormalized — matched bit-for-bit)
- // so a gated-off frame matches the plain pipeline to within float
- // summation-order rounding (review fix round 6, N2: terrain's two
- // separate varyings vs the plain pipeline's one reassociate the sum
- // — measured mean |Δ| 0.007 on the offline scene, not bit-identical)
- // — see the matching comment in mesh_atmospheric.vert's accumulateLights.
- bool shadowGatedOff = (uShadowTextureAndFlags.w & 1u) == 0u;
- vec3 surfaceToLight = shadowGatedOff
- ? -uLights[0].dirAndRange.xyz
- : normalize(uShadowLightDirectionAndSource.xyz);
+ // Campaign OVERHAUL S5 #469: SkyDesc::GetLighting authors retail's one
+ // outdoor light direction. Keep the EXACT plain terrain.vert expression
+ // in every shadow gate state: negate uLights[0].dirAndRange.xyz without
+ // normalizing it. The selected celestial source remains pack-only input
+ // to fragment shadow projection and atmospheric volumetrics.
+ vec3 surfaceToLight = -uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, surfaceToLight), MIN_FACTOR);
// Preserve retail's authored lighting values, but keep the outdoor
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/AtmosphericLightingDirectionSpirvTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/AtmosphericLightingDirectionSpirvTests.cs
new file mode 100644
index 00000000..9c9d7f0c
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/AtmosphericLightingDirectionSpirvTests.cs
@@ -0,0 +1,230 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+///
+/// S5 issue #469: inspect the committed production modules so source comments
+/// or an unused lookalike expression cannot satisfy the authored-light proof.
+///
+public sealed class AtmosphericLightingDirectionSpirvTests
+{
+ private const ushort OpExtInst = 12;
+ private const ushort OpConstant = 43;
+ private const ushort OpVariable = 59;
+ private const ushort OpLoad = 61;
+ private const ushort OpAccessChain = 65;
+ private const ushort OpVectorShuffle = 79;
+ private const ushort OpDecorate = 71;
+ private const ushort OpFNegate = 127;
+ private const ushort OpDot = 148;
+
+ private const uint DecorationBinding = 33;
+ private const uint DecorationDescriptorSet = 34;
+ private const uint SceneLightingSet = 1;
+ private const uint SceneLightingBinding = 1;
+ private const uint PackUniformSet = 3;
+ private const uint DirectionalShadowBinding = 6;
+
+ [Theory]
+ [InlineData("mesh_atmospheric.vert.spv", false)]
+ [InlineData("terrain_atmospheric.vert.spv", true)]
+ public void ProductionVertexModule_DotsTheNegatedAuthoredDirectionWithoutNormalization(
+ string fileName,
+ bool terrain)
+ {
+ Spirv module = Spirv.Read(Shader(fileName));
+
+ Assert.Empty(module.RootVariables(PackUniformSet, DirectionalShadowBinding));
+ uint sceneLighting = Assert.Single(module.RootVariables(SceneLightingSet, SceneLightingBinding));
+ Instruction direction = Assert.Single(module.Instructions, instruction =>
+ instruction.OpCode == OpAccessChain
+ && instruction.Operands[0] == sceneLighting
+ && IsAuthoredDirectionPath(module, instruction, terrain));
+ Instruction loaded = Assert.Single(module.WithOperand(OpLoad, direction.ResultId));
+ Instruction xyz = Assert.Single(module.WithOperand(OpVectorShuffle, loaded.ResultId));
+ Instruction negated = Assert.Single(module.WithOperand(OpFNegate, xyz.ResultId));
+ Instruction dot = Assert.Single(module.WithOperand(OpDot, negated.ResultId));
+
+ Assert.Equal(negated.ResultId, dot.Operands[1]);
+ }
+
+ [Theory]
+ [InlineData("mesh_atmospheric.frag.spv")]
+ [InlineData("terrain_atmospheric.frag.spv")]
+ [InlineData("atmospheric_volumetric.frag.spv")]
+ public void ProductionShadowAndVolumetricModules_StillNormalizeTheCelestialProjectionDirection(
+ string fileName)
+ {
+ Spirv module = Spirv.Read(Shader(fileName));
+ uint shadowBlock = Assert.Single(module.RootVariables(PackUniformSet, DirectionalShadowBinding));
+ Instruction direction = Assert.Single(module.Instructions, instruction =>
+ instruction.OpCode == OpAccessChain
+ && instruction.Operands[0] == shadowBlock
+ && instruction.Operands.Length == 2
+ && module.Constant(instruction.Operands[1]) == 5u);
+ Instruction loaded = Assert.Single(module.WithOperand(OpLoad, direction.ResultId));
+ Instruction xyz = Assert.Single(module.WithOperand(OpVectorShuffle, loaded.ResultId));
+
+ Assert.Contains(
+ module.Instructions,
+ instruction => instruction.OpCode == OpExtInst
+ && instruction.Operands.Contains(xyz.ResultId));
+ }
+
+ [Fact]
+ public void ParentPlainShadersAndReceiverSelectionSourcesRemainByteExact()
+ {
+ AssertNormalizedTextHash(
+ "d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2",
+ "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.vert");
+ AssertBinaryHash(
+ "6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4",
+ "src", "AcDream.App", "Rendering", "Shaders", "spv", "mesh_modern.vert.spv");
+ AssertNormalizedTextHash(
+ "b80c201771b6adf3a13b5564492c5188f53a59e61b4d730fb5037ae322b0dde0",
+ "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag");
+ AssertBinaryHash(
+ "bd47fe8a33e0f1d025fe48fd95b034ba6fe591a6d20e88e636c86238d8773db1",
+ "src", "AcDream.App", "Rendering", "Shaders", "spv", "mesh_modern.frag.spv");
+ AssertNormalizedTextHash(
+ "faf6855222cb2b09da6697c93a93f35cbc57c27bbc124055a6d235c72ef1a23a",
+ "src", "AcDream.App", "Rendering", "Shaders", "terrain_modern.vert");
+ AssertBinaryHash(
+ "8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c",
+ "src", "AcDream.App", "Rendering", "Shaders", "spv", "terrain_modern.vert.spv");
+ AssertNormalizedTextHash(
+ "a9f0ad2e679b68ee2de6c4877202f6f5e5089d0f7368fcedd6048891c6848915",
+ "src", "AcDream.App", "Rendering", "Shaders", "terrain_modern.frag");
+ AssertBinaryHash(
+ "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
+ "src", "AcDream.App", "Rendering", "Shaders", "spv", "terrain_modern.frag.spv");
+ AssertNormalizedTextHash(
+ "58c54e02bc0ba1d1086f05ef211e58eb6ba4d28d9a6be9ce3027509fee93ccf0",
+ "src", "AcDream.App", "Rendering", "Wb", "WbDrawDispatcher.DirectionalShadowReceivers.cs");
+ AssertNormalizedTextHash(
+ "ef33fb37b019e6b32932fb501ed87721dab496611c580cb046b8f1e80249efe2",
+ "src", "AcDream.App", "Rendering", "TerrainModernRenderer.DirectionalShadowReceivers.cs");
+ AssertNormalizedTextHash(
+ "55c6512f164376877c8bf28bff7487b7a2ce18ec963798eaeaf795c3a043c6b4",
+ "src", "AcDream.App", "Rendering", "Packs", "RenderPackReceiverPipelineCoordinator.cs");
+ AssertNormalizedTextHash(
+ "34186c77d66bd22aaeff439b31a0ae0a7af8d611ec7d4a3429fc40705034e028",
+ "src", "AcDream.App", "Rendering", "DirectionalShadowReceiver.cs");
+ }
+
+ private static bool IsAuthoredDirectionPath(
+ Spirv module,
+ in Instruction instruction,
+ bool terrain)
+ {
+ if (instruction.Operands.Length != 4
+ || module.Constant(instruction.Operands[1]) != 0u
+ || module.Constant(instruction.Operands[3]) != 1u)
+ return false;
+
+ return !terrain || module.Constant(instruction.Operands[2]) == 0u;
+ }
+
+ private static void AssertNormalizedTextHash(string expected, params string[] relativePath)
+ {
+ string text = File.ReadAllText(Path.Combine([RepositoryRoot(), .. relativePath]))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+ Assert.Equal(expected, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant());
+ }
+
+ private static void AssertBinaryHash(string expected, params string[] relativePath) =>
+ Assert.Equal(
+ expected,
+ Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(
+ Path.Combine([RepositoryRoot(), .. relativePath])))).ToLowerInvariant());
+
+ private static string Shader(string fileName) => Path.Combine(
+ RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv", fileName);
+
+ private readonly record struct Instruction(ushort OpCode, uint ResultId, uint[] Operands);
+
+ private sealed class Spirv
+ {
+ private readonly Dictionary> _decorations = [];
+ private readonly Dictionary _constants = [];
+
+ private Spirv()
+ {
+ }
+
+ internal Dictionary Variables { get; } = [];
+ internal List Instructions { get; } = [];
+
+ internal static Spirv Read(string path)
+ {
+ byte[] bytes = File.ReadAllBytes(path);
+ Assert.True(bytes.Length >= 20 && bytes.Length % sizeof(uint) == 0, $"{path} is not SPIR-V.");
+ uint[] words = new uint[bytes.Length / sizeof(uint)];
+ Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
+ Assert.Equal(0x07230203u, words[0]);
+
+ var module = new Spirv();
+ for (int index = 5; index < words.Length;)
+ {
+ int wordCount = checked((int)(words[index] >> 16));
+ ushort opCode = checked((ushort)(words[index] & 0xFFFFu));
+ Assert.True(wordCount > 0 && index + wordCount <= words.Length);
+
+ if (opCode == OpDecorate && wordCount >= 4)
+ {
+ uint target = words[index + 1];
+ if (!module._decorations.TryGetValue(target, out Dictionary? values))
+ module._decorations[target] = values = [];
+ values[words[index + 2]] = words[index + 3];
+ }
+ else if (opCode == OpConstant && wordCount >= 4)
+ {
+ module._constants[words[index + 2]] = words[index + 3];
+ }
+ else if (opCode == OpVariable && wordCount >= 4)
+ {
+ module.Variables[words[index + 2]] = words[index + 3];
+ }
+
+ if (HasResult(opCode))
+ {
+ module.Instructions.Add(new Instruction(
+ opCode,
+ words[index + 2],
+ words.AsSpan(index + 3, wordCount - 3).ToArray()));
+ }
+ index += wordCount;
+ }
+ return module;
+ }
+
+ internal uint? Constant(uint id) => _constants.TryGetValue(id, out uint value) ? value : null;
+
+ internal IEnumerable RootVariables(uint set, uint binding) => Variables.Keys
+ .Where(id => Decoration(id, DecorationDescriptorSet) == set)
+ .Where(id => Decoration(id, DecorationBinding) == binding);
+
+ internal IEnumerable WithOperand(ushort opCode, uint operand) =>
+ Instructions.Where(instruction => instruction.OpCode == opCode
+ && instruction.Operands.Contains(operand));
+
+ private uint? Decoration(uint id, uint decoration) =>
+ _decorations.TryGetValue(id, out Dictionary? values)
+ && values.TryGetValue(decoration, out uint value)
+ ? value
+ : null;
+
+ private static bool HasResult(ushort opCode) => opCode is
+ OpExtInst or OpLoad or OpAccessChain or OpVectorShuffle or OpFNegate or OpDot;
+ }
+
+ private static string RepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ directory = directory.Parent;
+ return directory?.FullName
+ ?? throw new InvalidOperationException("Could not locate the repository root.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.AtmosphericLighting.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.AtmosphericLighting.cs
new file mode 100644
index 00000000..485e8ca8
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.AtmosphericLighting.cs
@@ -0,0 +1,310 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
+using AcDream.App.Rendering.Packs;
+using AcDream.Core.Lighting;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Vk;
+
+public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
+{
+ [Trait("Lane", "Vulkan")]
+ [Fact]
+ public void CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate()
+ {
+ lock (VulkanLock)
+ {
+ string shaderDirectory = Path.Combine(
+ RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
+ using var host = HeadlessVulkanHost.Create(shaderDirectory);
+
+ (Pixel meshOff, Pixel meshOn) = RenderMeshLightingPair(host);
+ (Pixel terrainOff, Pixel terrainOn) = RenderTerrainLightingPair(host);
+
+ Assert.Equal(meshOff, meshOn);
+ Assert.Equal(terrainOff, terrainOn);
+ AssertAuthoredHalfIntensity(meshOff, "mesh");
+ AssertAuthoredHalfIntensity(terrainOff, "terrain");
+ }
+ }
+
+ [Fact]
+ public void AtmosphericLightingPixelWitness_IsOwnedByTheDedicatedVulkanLane()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ RepositoryRoot(), "tests", "AcDream.App.Tests", "Rendering", "Gpu", "Vk",
+ "MeshModernSharedIndexOffscreenTests.AtmosphericLighting.cs"));
+ Assert.Matches(
+ new Regex(
+ @"\[Trait\(""Lane"", ""Vulkan""\)\]\s*"
+ + @"\[Fact\]\s*public void CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate",
+ RegexOptions.Singleline),
+ source);
+ Assert.Equal(1, Count(source, "[Trait(\"Lane\", \"Vulkan\")]"));
+ }
+
+ private static (Pixel Disabled, Pixel Enabled) RenderMeshLightingPair(HeadlessVulkanHost host)
+ {
+ VulkanGpuDevice device = host.Device;
+ using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
+ "s5-469-mesh-vertices",
+ 4 * Marshal.SizeOf(),
+ GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
+ using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription(
+ "s5-469-mesh-indices",
+ 6 * sizeof(ushort),
+ GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
+ Vertex[] vertexData =
+ [
+ new(new Vector3(-0.32f, -0.32f, 0f), Vector3.UnitZ, Vector2.Zero),
+ new(new Vector3( 0.32f, -0.32f, 0f), Vector3.UnitZ, Vector2.UnitX),
+ new(new Vector3( 0.32f, 0.32f, 0f), Vector3.UnitZ, Vector2.One),
+ new(new Vector3(-0.32f, 0.32f, 0f), Vector3.UnitZ, Vector2.UnitY),
+ ];
+ vertices.Upload(0, MemoryMarshal.AsBytes(vertexData));
+ indices.Upload(0, MemoryMarshal.AsBytes([0, 1, 2, 2, 3, 0]));
+
+ using IGpuRenderTarget target = CreateTarget(device, "s5-469-mesh-offscreen");
+ using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = "s5-469-mesh-atmospheric",
+ Shaders = new GpuShaderSet("mesh_atmospheric"),
+ VertexLayout = GpuVertexLayout.WorldMesh,
+ Topology = GpuPrimitiveTopology.TriangleList,
+ Blend = GpuBlendMode.None,
+ Depth = GpuDepthState.Disabled,
+ Cull = GpuCullMode.None,
+ UsesRenderPackShaderAbi = true,
+ SampleCount = 1,
+ });
+ Assert.False(pipeline.Description.Shaders.HasEmbeddedSpirv);
+ Assert.Equal("mesh_atmospheric", pipeline.Description.Shaders.Name);
+
+ using (IGpuFrame frame = device.BeginFrame())
+ {
+ using IGpuPassEncoder encoder = BeginPass(frame, target, "s5-469-mesh-lighting");
+ encoder.BindPipeline(pipeline);
+ encoder.SetPushConstants(GpuPushConstants.Default);
+
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstances,
+ [
+ Matrix4x4.CreateTranslation(-0.48f, 0f, 0f),
+ Matrix4x4.CreateTranslation( 0.48f, 0f, 0f),
+ ]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageBatches,
+ [new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 0u)]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [0u, 0u]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, [GlobalLight.Zero]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets,
+ Enumerable.Repeat(-1, 16).ToArray());
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [0u, 0u]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [1f, 1f]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting,
+ [new Vector2(0f, 1f), new Vector2(0f, 1f)]);
+ BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [0u, 0u]);
+ BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, AuthoredHalfLight());
+ BindUniform(frame, encoder, GpuBindingModel.UniformAtmosphericFrame, default(AtmosphericFrameUniforms));
+
+ encoder.BindVertexBuffer(0, vertices, 0);
+ encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16);
+ BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
+ ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
+ encoder.DrawIndexed(6, 1, 0, 0, 0);
+ BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
+ ShadowUniforms(device.DefaultTextureSlot, enabled: true, Vector3.UnitX));
+ encoder.DrawIndexed(6, 1, 0, 0, 1);
+ }
+
+ device.WaitIdle();
+ return ReadLightingPair(host, target);
+ }
+
+ private static (Pixel Disabled, Pixel Enabled) RenderTerrainLightingPair(HeadlessVulkanHost host)
+ {
+ VulkanGpuDevice device = host.Device;
+ TerrainVertex[] terrain =
+ [
+ .. TerrainQuad(-0.48f),
+ .. TerrainQuad(0.48f),
+ ];
+ using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
+ "s5-469-terrain-vertices",
+ terrain.Length * Marshal.SizeOf(),
+ GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
+ vertices.Upload(0, MemoryMarshal.AsBytes(terrain));
+
+ using IGpuRenderTarget target = CreateTarget(device, "s5-469-terrain-offscreen");
+ using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = "s5-469-terrain-atmospheric",
+ Shaders = new GpuShaderSet("terrain_atmospheric"),
+ VertexLayout = TerrainModernRenderer.TerrainVertexLayout,
+ Topology = GpuPrimitiveTopology.TriangleList,
+ Blend = GpuBlendMode.None,
+ Depth = GpuDepthState.Disabled,
+ Cull = GpuCullMode.None,
+ UsesRenderPackShaderAbi = true,
+ SampleCount = 1,
+ });
+ Assert.False(pipeline.Description.Shaders.HasEmbeddedSpirv);
+ Assert.Equal("terrain_atmospheric", pipeline.Description.Shaders.Name);
+
+ using (IGpuFrame frame = device.BeginFrame())
+ {
+ using IGpuPassEncoder encoder = BeginPass(frame, target, "s5-469-terrain-lighting");
+ encoder.BindPipeline(pipeline);
+ GpuPushConstants constants = GpuPushConstants.Default;
+ constants.TextureIndexA = device.DefaultTextureSlot.Index;
+ constants.TextureIndexB = device.DefaultTextureSlot.Index;
+ encoder.SetPushConstants(constants);
+ BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, AuthoredHalfLight());
+ BindTerrainTiling(frame, encoder);
+ encoder.BindVertexBuffer(0, vertices, 0);
+
+ BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
+ ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
+ encoder.Draw(6, 1, 0, 0);
+ BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
+ ShadowUniforms(device.DefaultTextureSlot, enabled: true, Vector3.UnitX));
+ encoder.Draw(6, 1, 6, 0);
+ }
+
+ device.WaitIdle();
+ return ReadLightingPair(host, target);
+ }
+
+ private static IGpuRenderTarget CreateTarget(VulkanGpuDevice device, string name) =>
+ device.CreateRenderTarget(new GpuRenderTargetDescription(
+ name,
+ Extent,
+ Extent,
+ GpuTextureFormat.Rgba8UnormRenderTarget,
+ DepthFormat: null,
+ SampleCount: 1));
+
+ private static IGpuPassEncoder BeginPass(IGpuFrame frame, IGpuRenderTarget target, string name) =>
+ frame.BeginPass(new GpuPassDescription
+ {
+ Name = name,
+ Color = new GpuColorAttachment(
+ target,
+ GpuLoadOp.Clear,
+ GpuStoreOp.Store,
+ new Vector4(0f, 0f, 0f, 1f)),
+ Depth = null,
+ SampleCount = 1,
+ });
+
+ private static SceneLightingUbo AuthoredHalfLight() => new()
+ {
+ Light0 = new UboLight
+ {
+ PosAndKind = new Vector4(0f, 0f, 0f, 0f),
+ DirAndRange = new Vector4(0f, 0f, -0.5f, 0f),
+ ColorAndIntensity = Vector4.One,
+ ConeAngleEtc = Vector4.Zero,
+ },
+ CellAmbient = new Vector4(0f, 0f, 0f, 1f),
+ };
+
+ private static DirectionalShadowUniforms ShadowUniforms(
+ GpuTextureSlot texture,
+ bool enabled,
+ Vector3 celestialSurfaceToLight) => new(
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ Vector4.Zero,
+ new Vector4(0f, 1f, 0f, 1f),
+ Vector4.Zero,
+ new UInt4(texture.Index, 0u, 1u, enabled ? 1u : 0u),
+ new Vector4(celestialSurfaceToLight, 1f));
+
+ private static void BindTerrainTiling(IGpuFrame frame, IGpuPassEncoder encoder)
+ {
+ GpuRingAllocation allocation = frame.AllocateRing(
+ TerrainTextureTilingTable.UniformBufferBytes,
+ GpuRingUsage.Uniform);
+ allocation.Data.Clear();
+ allocation.AsSpan()[0] = 1f;
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformTerrainTiling,
+ allocation.Buffer,
+ allocation.OffsetBytes,
+ (uint)allocation.Data.Length);
+ }
+
+ private static TerrainVertex[] TerrainQuad(float x) =>
+ [
+ new(new Vector3(x - 0.32f, -0.32f, 0.5f)),
+ new(new Vector3(x + 0.32f, -0.32f, 0.5f)),
+ new(new Vector3(x + 0.32f, 0.32f, 0.5f)),
+ new(new Vector3(x - 0.32f, -0.32f, 0.5f)),
+ new(new Vector3(x + 0.32f, 0.32f, 0.5f)),
+ new(new Vector3(x - 0.32f, 0.32f, 0.5f)),
+ ];
+
+ private static (Pixel Disabled, Pixel Enabled) ReadLightingPair(
+ HeadlessVulkanHost host,
+ IGpuRenderTarget target)
+ {
+ VulkanGpuRenderTarget vkTarget = Assert.IsType(target);
+ byte[] pixels = ReadBack(
+ host.Vk,
+ host.PhysicalDevice,
+ host.LogicalDevice,
+ host.Queue,
+ host.QueueFamily,
+ vkTarget.ColorResult.Image);
+ return (PixelAt(pixels, 16, 32), PixelAt(pixels, 48, 32));
+ }
+
+ private static Pixel PixelAt(byte[] pixels, int x, int y)
+ {
+ int offset = ((y * Extent) + x) * 4;
+ return new Pixel(pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3]);
+ }
+
+ private static void AssertAuthoredHalfIntensity(Pixel pixel, string receiver)
+ {
+ Assert.InRange(pixel.R, (byte)126, (byte)129);
+ Assert.InRange(pixel.G, (byte)126, (byte)129);
+ Assert.InRange(pixel.B, (byte)126, (byte)129);
+ Assert.True(pixel.A >= 253, $"{receiver} alpha was {pixel.A}, expected opaque.");
+ }
+
+ private static int Count(string text, string token)
+ {
+ int count = 0;
+ for (int index = 0; (index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0;)
+ {
+ count++;
+ index += token.Length;
+ }
+ return count;
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ private readonly record struct TerrainVertex(
+ Vector3 Position,
+ Vector3 Normal,
+ uint Packed0,
+ uint Packed1,
+ uint Packed2,
+ uint Packed3)
+ {
+ internal TerrainVertex(Vector3 position)
+ : this(position, Vector3.UnitZ, 0xFFFF_FF00u, uint.MaxValue, uint.MaxValue, 0u)
+ {
+ }
+ }
+
+ private readonly record struct Pixel(byte R, byte G, byte B, byte A);
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
index 3837cb6f..9d6f2761 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.cs
@@ -13,7 +13,7 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// mesh_modern SPIR-V pair, binds a nonzero shared-transform prefix and local
/// sidecars, submits an actual offscreen draw, and the test reads its pixels.
///
-public sealed unsafe class MeshModernSharedIndexOffscreenTests
+public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
{
private const int Extent = 64;
private const uint Prefix = 2;
diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
index 608d3c04..895e81e0 100644
--- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
@@ -1224,26 +1224,13 @@ public sealed class AtmosphericPostProcessGraphTests
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
[Fact]
- public void ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear()
+ public void ReceiverVertexShadersAlwaysUseTheExactUnnormalizedPlainLightDirection()
{
- // Campaign VM VM6 review fix round 5 (F1 BLOCKER, test T3), hardened
- // round 6 (N1): the original version of this test asserted only
- // that the plain-pipeline direction SUBSTRING appeared somewhere
- // in the receiver file — but the round-5 fix's own explanatory
- // comments ALSO quote that exact substring in prose ("(-uLights[i]
- // .dirAndRange.xyz, ... matched bit-for-bit)"), so the check was
- // satisfied by the COMMENT even with the CODE mutated. Mutation-
- // tested locally against this rewritten test: (1) swapping the
- // ternary's true/false operands — PASSED the old substring-only
- // test, FAILS this one; (2) deleting the fallback entirely
- // (always taking the shadow-block direction, i.e. collapsing the
- // ternary to just its false arm) — PASSED the old test, FAILS
- // this one. Comment lines are stripped before matching, and the
- // assertion is the exact branch SHAPE — the gated-off condition,
- // then the plain uLights expression on the TRUE arm, then
- // uShadowLightDirectionAndSource on the FALSE arm — via an
- // ordered regex, not a substring search that a comment can also
- // satisfy.
+ // Campaign OVERHAUL S5 #469: the atmospheric pack may direct
+ // shadow projection at a selected celestial body, but both receiver
+ // vertex shaders keep retail's one authored, unnormalized base-light
+ // direction in every gate state. Strip comments so prose cannot
+ // satisfy any of these production-expression checks.
string shaderRoot = Path.Combine(
RepositoryRoot(),
"src",
@@ -1259,9 +1246,12 @@ public sealed class AtmosphericPostProcessGraphTests
string terrainModern = StripLineComments(
File.ReadAllText(Path.Combine(shaderRoot, "terrain_modern.vert")));
- const string flagTest = "(uShadowTextureAndFlags.w & 1u) == 0u";
- Assert.Contains(flagTest, meshAtmospheric, StringComparison.Ordinal);
- Assert.Contains(flagTest, terrainAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("shadowGatedOff", meshAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("shadowGatedOff", terrainAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("uShadowTextureAndFlags", meshAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("uShadowTextureAndFlags", terrainAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("uShadowLightDirectionAndSource", meshAtmospheric, StringComparison.Ordinal);
+ Assert.DoesNotContain("uShadowLightDirectionAndSource", terrainAtmospheric, StringComparison.Ordinal);
// mesh: both files negate the per-light uniform inline (no
// intermediate variable), so the literal expression matches
@@ -1271,10 +1261,9 @@ public sealed class AtmosphericPostProcessGraphTests
Assert.Contains(meshPlainDirection, meshModern, StringComparison.Ordinal);
Assert.Contains(meshPlainDirection, meshAtmospheric, StringComparison.Ordinal);
- // terrain: terrain_modern.vert reads the uniform into `sunDir`
- // then negates that variable two lines later, so only the
- // shared uniform-access substring (not the exact negation
- // placement) is asserted identical between the two files.
+ // terrain_modern.vert reads the same uniform into `sunDir` and then
+ // negates it; the atmospheric receiver spells that identical value
+ // inline so its split directional varying remains unchanged.
const string terrainLightUniform = "uLights[0].dirAndRange.xyz";
Assert.Contains(terrainLightUniform, terrainModern, StringComparison.Ordinal);
Assert.Contains(
@@ -1282,24 +1271,19 @@ public sealed class AtmosphericPostProcessGraphTests
terrainAtmospheric,
StringComparison.Ordinal);
- // Branch-shape guard (N1): fails on a swapped ternary or a
- // deleted fallback even though the substrings above would still
- // be present somewhere in the (comment-stripped) file.
- var meshBranchShape = new Regex(
- @"shadowGatedOff\s*\?\s*-uLights\[i\]\.dirAndRange\.xyz\s*:\s*normalize\(uShadowLightDirectionAndSource\.xyz\)",
+ var meshDirectionShape = new Regex(
+ @"vec3\s+Ldir\s*=\s*-uLights\[i\]\.dirAndRange\.xyz\s*;\s*float\s+ndl\s*=\s*max\(0\.0,\s*dot\(N,\s*Ldir\)\)",
RegexOptions.Singleline);
Assert.True(
- meshBranchShape.IsMatch(meshAtmospheric),
- "mesh_atmospheric.vert must branch exactly: "
- + "shadowGatedOff ? -uLights[i].dirAndRange.xyz : normalize(uShadowLightDirectionAndSource.xyz)");
+ meshDirectionShape.IsMatch(meshAtmospheric),
+ "mesh_atmospheric.vert must feed the exact unnormalized -uLights[i] direction directly to N dot L.");
- var terrainBranchShape = new Regex(
- @"shadowGatedOff\s*\?\s*-uLights\[0\]\.dirAndRange\.xyz\s*:\s*normalize\(uShadowLightDirectionAndSource\.xyz\)",
+ var terrainDirectionShape = new Regex(
+ @"vec3\s+surfaceToLight\s*=\s*-uLights\[0\]\.dirAndRange\.xyz\s*;\s*vec3\s+sunCol[\s\S]*?float\s+L\s*=\s*max\(dot\(vWorldNormal,\s*surfaceToLight\),\s*MIN_FACTOR\)",
RegexOptions.Singleline);
Assert.True(
- terrainBranchShape.IsMatch(terrainAtmospheric),
- "terrain_atmospheric.vert must branch exactly: "
- + "shadowGatedOff ? -uLights[0].dirAndRange.xyz : normalize(uShadowLightDirectionAndSource.xyz)");
+ terrainDirectionShape.IsMatch(terrainAtmospheric),
+ "terrain_atmospheric.vert must feed the exact unnormalized -uLights[0] direction directly to N dot L.");
}
///
@@ -1495,6 +1479,12 @@ public sealed class AtmosphericPostProcessGraphTests
StringComparison.Ordinal);
Assert.Contains("pack-off does not build, select, upload, or draw shadow work", ia24,
StringComparison.Ordinal);
+ Assert.Contains("authored, unnormalized `uLights` direction remains the sole base-light direction", ia24,
+ StringComparison.Ordinal);
+ Assert.Contains("selected celestial source drives only the opt-in shadow and volumetric projection", ia24,
+ StringComparison.Ordinal);
+ Assert.Contains("light/shadow-angle separation", ia24, StringComparison.Ordinal);
+ Assert.DoesNotContain("same celestial source instead", ia24, StringComparison.Ordinal);
static void AssertGraphUsesTypedSelection(string source, string methodName)
{