fix(render): foliage wind keys on the DAT-classified WeatherKind, not the day-group index (Campaign VM VM6)

FoliageWindByDayGroup / FoliageWindDayGroupPoint(int ActiveDayGroup, ...)
becomes FoliageWindByWeather / FoliageWindWeatherPoint(string WeatherKind,
...) in AtmospherePolicyDeclaration (Plugin.Abstractions is BCL-only, so
the key is the exact member name of AcDream.Core.World.WeatherKind rather
than the enum itself). The raw activeDayGroup index carries no weather
meaning by itself; WeatherState.cs already classifies each day group's
authored DAT name into one of five real weather kinds, and that fact was
already threaded through AtmosphericFrameInputs.Weather / uAtmosphereWeather.x
— this reuses it instead of guessing an index-to-category mapping.

Built-in table (BuiltInAtmosphericRenderPack.AtmospherePolicy()): Clear
0.25/0.15, Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm
1.00/0.75 — all five WeatherKind members declared, the invented "Cloudy"
row dropped. RenderPackAtmospherePolicyEvaluation.FoliageWind now takes a
WeatherKind and matches by weather.ToString() (ordinal) against each
declared point's name; a kind absent from the table falls back to the
declared Clear row, then to (0,0) if Clear itself is undeclared. The
delta-seconds EMA interpolation (EaseTowardTarget) is unchanged.
AtmosphericPostProcessGraph.ResolveFoliageWind and its two callers
(RenderPostProcess via inputs.Weather; RenderDirectionalShadows via
foundation.Atmosphere.Kind) now pass WeatherKind instead of the day-group
int.

RenderPackValidation.ValidateAtmosphere (runs for every pack declaring an
AtmospherePolicy, not gated to Tier2/shadow packs) now rejects an unknown
or non-exact-case weather-kind name and a repeated kind, mirroring the
existing ActiveDayGroupMultiplier duplicate-key check.

Tests: RenderPackAtmospherePolicyEvaluationTests rewritten for the
kind-keyed API (all five kinds resolve to their declared row, an unlisted
kind falls back to Clear, ordinal exact-case matching, null-table
handling); RenderPackSpirvValidatorTests gains four descriptor-validation
cases (unknown name, wrong case, duplicate kind, the five-kind table
accepted); AtmosphericPostProcessGraphTests' three foliage-wind cases now
select WeatherKind.Storm via `with` instead of an assumed day-group index.

Spot-check (per the coordinator's ask, not changed here): yes —
ActiveDayGroupMultiplier / EvaluateDayGroupPolicy (pre-existing, Campaign
AR/VM3-era — BuiltInAtmosphericRenderPack.AtmospherePolicy()'s three rows
`new ActiveDayGroupMultiplier(0, 1.0), (1, 0.35), (2, 0.20)`) key the
sun-ray/shadow/volumetric day-group strength multiplier by the same raw
activeDayGroup index with an undocumented assumed meaning (0=brightest ...
2=dimmest), the identical class of issue this commit fixes for foliage
wind. Left unchanged per instruction; flagging for the coordinator to file.

Full solution Debug and Release builds green. App hermetic filter
6024/6026 — the same 2 pre-existing failures as VM6a/VM6b. Both were
re-run in isolation per the verification ask: both still fail alone (not
a load-flake in this environment) — confirmed via git stash earlier this
session that both already fail on the unmodified pre-VM6 baseline, so
they are pre-existing and unrelated to this change. Core.Tests hermetic
4697/4697. RenderPackValidator.Tests 30/30. No shader/spv changes in this
commit (pure C#/docs fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 01:07:54 +02:00
parent 39e8408c7d
commit 6cc5e183b9
10 changed files with 325 additions and 106 deletions

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 1. Intentional architecture (IA) — 22 active rows (IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) ## 1. Intentional architecture (IA) — 22 active rows (IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
| 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-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-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.** 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. 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`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` | This is the user-requested headline graphics enhancement and is strictly opt-in. The retail path remains the default and authoritative fallback; pack-off does not build/select shadow work or change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail. A wrong celestial identity/transform or stale source transition would visibly misalign shadows from the sky; pack-off output changing would violate the campaign's primary safety contract. | `SkyDesc::GetLighting @0x00500A80`; `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research note | | IA-24 | **Filed 2026-08-22, Campaign AR.** 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. 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`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` | This is the user-requested headline graphics enhancement and is strictly opt-in. The retail path remains the default and authoritative fallback; pack-off does not build/select shadow work or change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail. A wrong celestial identity/transform or stale source transition would visibly misalign shadows from the sky; pack-off output changing would violate the campaign's primary safety contract. | `SkyDesc::GetLighting @0x00500A80`; `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research note |
| 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 active day group 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 + `FoliageWindByDayGroup`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`, `mesh_detail`) 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` (bit 31 entity-id namespace, existing acdream mechanism, not retail) | | 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`, `mesh_detail`) 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` (bit 31 entity-id namespace, existing acdream mechanism, not retail) |
--- ---

View file

@ -474,18 +474,26 @@ direction to read, and the register row says so.
### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding) ### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding)
Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the exact Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the
design above. Settings (`BuiltInAtmosphericRenderPack.Settings()`): design above; a same-day fix-round commit corrected the weather-table key.
`wind-enabled` (bool, default on), `wind-strength` (02×, default 1.0), Settings (`BuiltInAtmosphericRenderPack.Settings()`): `wind-enabled` (bool,
`wind-direction-degrees` (0360°, default 225 — no authored retail wind default on), `wind-strength` (02×, default 1.0), `wind-direction-degrees`
direction exists to read), `wind-lean-metres` (default 0.25), (0360°, default 225 — no authored retail wind direction exists to read),
`wind-branch-metres` (default 0.15), `wind-flutter-metres` (default 0.05, `wind-lean-metres` (default 0.25), `wind-branch-metres` (default 0.15),
forced to 0 on the Low preset), `wind-canopy-height-metres` (default 8). The `wind-flutter-metres` (default 0.05, forced to 0 on the Low preset),
Clear/Cloudy/Overcast/Rainy mean/gust rows live in `wind-canopy-height-metres` (default 8). The mean/gust rows live in
`AtmospherePolicyDeclaration.FoliageWindByDayGroup`, keyed by the SAME `AtmospherePolicyDeclaration.FoliageWindByWeather`, keyed by NAME
day-group index convention `ActiveDayGroupMultipliers` already established (`FoliageWindWeatherPoint.WeatherKind`, an exact ordinal match against
two lines above it in `AtmosphericPolicy()` (0 Clear / 1 Cloudy / 2 Overcast; `AcDream.Core.World.WeatherKind`'s member names — `Clear` 0.25/0.15,
index 3 Rainy is new). Classification bits live in `Overcast` 0.60/0.35, `Rain` 0.85/0.60, `Snow` 0.35/0.20, `Storm` 1.00/0.75),
not the raw `activeDayGroup` index the original design used: the index
carries no weather meaning by itself, and `WeatherState.cs` already
classifies each day group's authored DAT name into one of these five real
kinds — the same fact `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads through the frame, reused here
instead of re-guessed. `RenderPackAtmospherePolicyEvaluation.FoliageWind`
matches by `weather.ToString()` and falls back to the declared Clear row for
an unlisted kind. Classification bits live in
`FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`) `FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`)
cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in
`WbDrawDispatcher.ClassifyBatches` (world receiver) and `WbDrawDispatcher.ClassifyBatches` (world receiver) and

View file

@ -420,7 +420,7 @@ internal sealed class AtmosphericPostProcessGraph :
worldMeshes.FoliageWindExclusions = _foliageWindExclusions; worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell; bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
AtmosphericFrameBufferBinding shadowAtmosphericFrame = AtmosphericFrameBufferBinding shadowAtmosphericFrame =
BuildShadowAtmosphericFrameBinding(frame, activeDayGroup, isOutdoor); BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
var input = new DirectionalSunShadowRenderInput( var input = new DirectionalSunShadowRenderInput(
environment, environment,
world.Camera.Camera.View, world.Camera.Camera.View,
@ -480,12 +480,12 @@ internal sealed class AtmosphericPostProcessGraph :
/// </summary> /// </summary>
private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding( private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding(
IGpuFrame frame, IGpuFrame frame,
int activeDayGroup, AcDream.Core.World.WeatherKind weather,
bool isOutdoor) bool isOutdoor)
{ {
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind( (Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
frame.Serial, frame.Serial,
activeDayGroup, weather,
isOutdoor); isOutdoor);
GpuRingAllocation allocation = frame.AllocateRing( GpuRingAllocation allocation = frame.AllocateRing(
AtmosphericFrameUniforms.SizeInBytes, AtmosphericFrameUniforms.SizeInBytes,
@ -530,13 +530,15 @@ internal sealed class AtmosphericPostProcessGraph :
/// for its own animation clock — replaces it when set, which is every /// for its own animation clock — replaces it when set, which is every
/// run but a differential/offline gate's.</para> /// run but a differential/offline gate's.</para>
/// ///
/// <para><b>The smoothing.</b> The per-day-group (mean, gust) target /// <para><b>The smoothing.</b> The per-weather-kind (mean, gust) target
/// from <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/> /// from <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/> —
/// eases toward its target using an exponential moving average over /// keyed by the DAT-classified <see cref="AcDream.Core.World.WeatherKind"/>,
/// <see cref="AcDream.Core.World.WeatherSystem.TransitionSeconds"/> (10 s, /// not the raw day-group index, which carries no weather meaning by
/// the same authored weather-transition constant retail-parity work /// itself — eases toward its target using an exponential moving average
/// already established) — so a day-group change never snaps. This state /// over <see cref="AcDream.Core.World.WeatherSystem.TransitionSeconds"/>
/// keeps evolving even while indoors or disabled; <c>wind-enabled</c> /// (10 s, the same authored weather-transition constant retail-parity
/// work already established) — so a weather change never snaps. This
/// state keeps evolving even while indoors or disabled; <c>wind-enabled</c>
/// off or <c>!isOutdoor</c> instead multiplies the OUTPUT by an exact /// off or <c>!isOutdoor</c> instead multiplies the OUTPUT by an exact
/// zero gate (never an asymptotic approach), so a settings toggle or a /// zero gate (never an asymptotic approach), so a settings toggle or a
/// cell transition reads exactly zero on the very next frame, and /// cell transition reads exactly zero on the very next frame, and
@ -545,7 +547,7 @@ internal sealed class AtmosphericPostProcessGraph :
/// </summary> /// </summary>
private (Vector4 ClockWind, Vector4 WindAmplitude) ResolveFoliageWind( private (Vector4 ClockWind, Vector4 WindAmplitude) ResolveFoliageWind(
long frameSerial, long frameSerial,
int activeDayGroup, AcDream.Core.World.WeatherKind weather,
bool isOutdoor) bool isOutdoor)
{ {
float clockSeconds = _windClockSecondsOverride float clockSeconds = _windClockSecondsOverride
@ -558,8 +560,8 @@ internal sealed class AtmosphericPostProcessGraph :
1f); 1f);
(float targetMean, float targetGust) = RenderPackAtmospherePolicyEvaluation (float targetMean, float targetGust) = RenderPackAtmospherePolicyEvaluation
.FoliageWind( .FoliageWind(
Descriptor.AtmospherePolicy?.FoliageWindByDayGroup, Descriptor.AtmospherePolicy?.FoliageWindByWeather,
activeDayGroup); weather);
targetMean *= _foliageWind.Strength; targetMean *= _foliageWind.Strength;
targetGust *= _foliageWind.Strength; targetGust *= _foliageWind.Strength;
_windMean = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget( _windMean = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
@ -689,7 +691,7 @@ internal sealed class AtmosphericPostProcessGraph :
: 0f; : 0f;
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind( (Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
frame.Serial, frame.Serial,
inputs.ActiveDayGroup, inputs.Weather,
inputs.IsOutdoor); inputs.IsOutdoor);
var frameUniforms = new AtmosphericFrameUniforms( var frameUniforms = new AtmosphericFrameUniforms(
new Vector4( new Vector4(

View file

@ -369,21 +369,25 @@ internal static class BuiltInAtmosphericRenderPack
new SunElevationResponsePoint(70, 0), new SunElevationResponsePoint(70, 0),
new SunElevationResponsePoint(90, 0), new SunElevationResponsePoint(90, 0),
], ],
// Campaign VM VM6: the plan's Clear/Cloudy/Overcast/Rainy rows, // Campaign VM VM6, corrected in the fix round: keyed by the
// keyed by the SAME activeDayGroup index convention this pack // DAT-classified AcDream.Core.World.WeatherKind, not the raw
// already established two lines above for ActiveDayGroupMultipliers // activeDayGroup index — WeatherState.cs's existing name
// (0 brightest/clearest ... 2 dimmest). That table only needed // classification (e.g. "Sunny" -> Clear, a name containing
// three rows; foliage wind needs a fourth (Rainy), so index 3 is // "fog"/"cloud"/"overcast"/"dark"/"storm"/"snow"/"rain" -> the
// new here. Dereth's DAT declares many more named day groups than // matching kind) already turns a day group's authored DAT name
// these four categories distinguish — an index absent from this // into one of these five real weather kinds; that fact was
// table (including every index above 3) gets zero wind rather // already threaded through AtmosphericFrameInputs.Weather /
// than guessing a category. // uAtmosphereWeather.x, so this table reuses it instead of
FoliageWindByDayGroup = // guessing a meaning for the raw index. Every WeatherKind member
// is declared; nothing falls through to the Clear fallback in
// production.
FoliageWindByWeather =
[ [
new FoliageWindDayGroupPoint(0, 0.25, 0.15), // Clear new FoliageWindWeatherPoint("Clear", 0.25, 0.15),
new FoliageWindDayGroupPoint(1, 0.45, 0.30), // Cloudy new FoliageWindWeatherPoint("Overcast", 0.60, 0.35),
new FoliageWindDayGroupPoint(2, 0.60, 0.35), // Overcast new FoliageWindWeatherPoint("Rain", 0.85, 0.60),
new FoliageWindDayGroupPoint(3, 0.85, 0.60), // Rainy new FoliageWindWeatherPoint("Snow", 0.35, 0.20),
new FoliageWindWeatherPoint("Storm", 1.00, 0.75),
], ],
}; };

View file

@ -1,3 +1,4 @@
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering; using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs; namespace AcDream.App.Rendering.Packs;
@ -45,26 +46,40 @@ internal static class RenderPackAtmospherePolicyEvaluation
fallback); fallback);
/// <summary> /// <summary>
/// Campaign VM VM6: exact day-group lookup for foliage wind — the same /// Campaign VM VM6, corrected in the fix round: exact weather-kind
/// shape as <see cref="ActiveDayGroupMultiplier"/>'s exact-match pattern /// lookup for foliage wind, keyed by the DAT-classified
/// (day groups are not ordered by "how windy," so there is nothing to /// <see cref="WeatherKind"/> the frame already carries
/// interpolate across them). A day group absent from the table returns /// (<c>AtmosphericFrameInputs.Weather</c> / <c>uAtmosphereWeather.x</c>)
/// (0, 0) — no wind is the safe default for an unclassified day group, /// — not the raw <c>activeDayGroup</c> index, which carries no weather
/// not full wind. The caller is responsible for smoothing the resolved /// meaning by itself. Matches by <c>weather.ToString()</c> (ordinal)
/// target over time; this method is a pure, stateless lookup. /// against each declared <see cref="FoliageWindWeatherPoint.WeatherKind"/>
/// name. The five kinds are not ordered by "how windy," so this is an
/// exact match, never an interpolation across them. A kind absent from
/// the table falls back to the declared Clear row (a weather kind the
/// classifier could not resolve is closer to "no weather data" than to
/// "assume it's windy"); if Clear itself is undeclared, the fallback is
/// (0, 0). The caller is responsible for smoothing the resolved target
/// over time; this method is a pure, stateless lookup.
/// </summary> /// </summary>
internal static (float Mean, float Gust) FoliageWind( internal static (float Mean, float Gust) FoliageWind(
IReadOnlyList<FoliageWindDayGroupPoint>? points, IReadOnlyList<FoliageWindWeatherPoint>? points,
int activeDayGroup) WeatherKind weather)
{ {
if (points is null) if (points is null)
return (0f, 0f); return (0f, 0f);
foreach (FoliageWindDayGroupPoint point in points) string kind = weather.ToString();
FoliageWindWeatherPoint? clear = null;
foreach (FoliageWindWeatherPoint point in points)
{ {
if (point.ActiveDayGroup == activeDayGroup) if (string.Equals(point.WeatherKind, kind, StringComparison.Ordinal))
return ((float)point.Mean, (float)point.Gust); return ((float)point.Mean, (float)point.Gust);
if (clear is null
&& string.Equals(point.WeatherKind, nameof(WeatherKind.Clear), StringComparison.Ordinal))
{
clear = point;
}
} }
return (0f, 0f); return clear is { } fallback ? ((float)fallback.Mean, (float)fallback.Gust) : (0f, 0f);
} }
/// <summary> /// <summary>

View file

@ -1482,6 +1482,39 @@ internal static class RenderPackValidator
|| !IsFiniteNonNegative(value.Multiplier)) || !IsFiniteNonNegative(value.Multiplier))
return Invalid($"Pack '{descriptor.Id}' has an invalid active-day-group mapping."); return Invalid($"Pack '{descriptor.Id}' has an invalid active-day-group mapping.");
} }
// Campaign VM VM6 fix round: FoliageWindByWeather is keyed by the
// DAT-classified AcDream.Core.World.WeatherKind name (exact,
// case-sensitive), not a raw day-group index — an unknown name or a
// repeated kind is rejected outright rather than silently ignored.
if (policy.FoliageWindByWeather is null)
return Invalid($"Pack '{descriptor.Id}' has a null foliage-wind weather-point list.");
var weatherKinds = new HashSet<string>(StringComparer.Ordinal);
foreach (FoliageWindWeatherPoint point in policy.FoliageWindByWeather)
{
if (!Enum.TryParse(
point.WeatherKind,
ignoreCase: false,
out AcDream.Core.World.WeatherKind parsedKind)
|| !Enum.IsDefined(parsedKind))
{
return Invalid(
$"Pack '{descriptor.Id}' declares an unknown foliage-wind weather "
+ $"kind '{point.WeatherKind}'.");
}
if (!weatherKinds.Add(point.WeatherKind))
{
return Invalid(
$"Pack '{descriptor.Id}' declares the foliage-wind weather kind "
+ $"'{point.WeatherKind}' more than once.");
}
if (!IsFiniteNonNegative(point.Mean) || !IsFiniteNonNegative(point.Gust))
{
return Invalid(
$"Pack '{descriptor.Id}' has an invalid foliage-wind mean/gust value "
+ $"for weather kind '{point.WeatherKind}'.");
}
}
return RenderPackValidationResult.Valid(); return RenderPackValidationResult.Valid();
RenderPackValidationResult ValidateCurve( RenderPackValidationResult ValidateCurve(

View file

@ -387,12 +387,25 @@ public sealed record ActiveDayGroupMultiplier(
double Multiplier); double Multiplier);
/// <summary> /// <summary>
/// Explicit mapping from an authored AC day group to the foliage-wind mean /// Explicit mapping from a DAT-classified weather kind to the foliage-wind
/// and gust strength for that group (both in the declared [0,1] range before /// mean and gust strength for that weather (both in the declared [0,1] range
/// the pack's global wind-strength setting scales them). Campaign VM VM6. /// before the pack's global wind-strength setting scales them). Campaign VM
/// VM6, corrected in the fix round: the day-group's raw <c>activeDayGroup</c>
/// INDEX carries no weather meaning by itself — <c>WeatherState.cs</c>'s
/// existing name classification is what turns a day group's DAT name into
/// one of the five real weather kinds, and that is what the host already
/// threads through <c>AtmosphericFrameInputs.Weather</c> / <c>uAtmosphereWeather.x</c>.
/// This is the same fact, keyed correctly instead of by index.
/// </summary> /// </summary>
public sealed record FoliageWindDayGroupPoint( /// <param name="WeatherKind">
int ActiveDayGroup, /// The exact member name of <c>AcDream.Core.World.WeatherKind</c> — one of
/// <c>"Clear"</c>, <c>"Overcast"</c>, <c>"Rain"</c>, <c>"Snow"</c>,
/// <c>"Storm"</c>. A plain string because this project (Plugin.Abstractions)
/// is BCL-only and cannot reference the Core enum; the host converts with
/// <c>WeatherKind.ToString()</c> (ordinal) when resolving.
/// </param>
public sealed record FoliageWindWeatherPoint(
string WeatherKind,
double Mean, double Mean,
double Gust); double Gust);
@ -424,18 +437,18 @@ public sealed record AtmospherePolicyDeclaration(
{ get; init; } = []; { get; init; } = [];
/// <summary> /// <summary>
/// Campaign VM VM6: exact per-day-group foliage-wind mean/gust targets, /// Campaign VM VM6: exact per-weather-kind foliage-wind mean/gust
/// looked up by the same categorical <c>activeDayGroup</c> id /// targets, looked up by the DAT-classified
/// <see cref="ActiveDayGroupMultipliers"/> already keys on (an exact /// <c>AcDream.Core.World.WeatherKind</c> (an exact match, not an
/// match, not an interpolation across day-group ids — day groups are not /// interpolation — the five kinds are not ordered by "how windy"). The
/// ordered by "how windy"). The host smooths the resolved (mean, gust) /// host smooths the resolved (mean, gust) target toward whatever this
/// target toward whatever this table returns over time using the /// table returns over time using the existing weather delta-seconds
/// existing weather delta-seconds clock, so a day-group change never /// clock, so a weather change never snaps. A kind absent from this table
/// snaps. A day group absent from this table gets zero wind. A pack /// falls back to the declared Clear row, then to (0, 0) if Clear itself
/// using foliage wind should declare an entry for every day group it /// is undeclared. Each declared <see cref="FoliageWindWeatherPoint.WeatherKind"/>
/// cares about distinguishing. /// must be one of the five real kind names and must appear at most once.
/// </summary> /// </summary>
public IReadOnlyList<FoliageWindDayGroupPoint> FoliageWindByDayGroup public IReadOnlyList<FoliageWindWeatherPoint> FoliageWindByWeather
{ get; init; } = []; { get; init; } = [];
/// <summary> /// <summary>

View file

@ -1228,11 +1228,12 @@ public sealed class AtmosphericPostProcessGraphTests
var device = new RecordingGpuDevice(); var device = new RecordingGpuDevice();
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f); using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
graph.PrepareWorldTarget(640, 480, 1); graph.PrepareWorldTarget(640, 480, 1);
// Rainy (index 3 in the built-in table) is the highest declared // Storm is the highest declared mean/gust — the exact target does
// mean/gust — the exact target does not matter here, only that the // not matter here, only that the gate still zeroes the output
// gate still zeroes the output despite a nonzero smoothed target. // despite a nonzero smoothed target.
AtmosphericFrameInputs indoors = Inputs(640, 480, activeDayGroup: 3) with AtmosphericFrameInputs indoors = Inputs(640, 480) with
{ {
Weather = WeatherKind.Storm,
IsOutdoor = false, IsOutdoor = false,
}; };
@ -1252,7 +1253,7 @@ public sealed class AtmosphericPostProcessGraphTests
windClockSecondsOverride: 12f, windClockSecondsOverride: 12f,
userSettingOverrides: new Dictionary<string, string> { ["wind-enabled"] = "false" }); userSettingOverrides: new Dictionary<string, string> { ["wind-enabled"] = "false" });
graph.PrepareWorldTarget(640, 480, 1); graph.PrepareWorldTarget(640, 480, 1);
AtmosphericFrameInputs outdoors = Inputs(640, 480, activeDayGroup: 3); AtmosphericFrameInputs outdoors = Inputs(640, 480) with { Weather = WeatherKind.Storm };
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors); AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
@ -1290,7 +1291,7 @@ public sealed class AtmosphericPostProcessGraphTests
IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1); IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1);
using IGpuFrame frame = device.BeginFrame(); using IGpuFrame frame = device.BeginFrame();
RecordWorldPass(frame, world); RecordWorldPass(frame, world);
AtmosphericFrameInputs inputs = Inputs(640, 480, activeDayGroup: 3); AtmosphericFrameInputs inputs = Inputs(640, 480) with { Weather = WeatherKind.Storm };
graph.RenderPostProcess(frame, in inputs); graph.RenderPostProcess(frame, in inputs);
AtmosphericFrameUniforms first = ReadLastFrameBlock(device); AtmosphericFrameUniforms first = ReadLastFrameBlock(device);

View file

@ -1,39 +1,76 @@
using AcDream.App.Rendering.Packs; using AcDream.App.Rendering.Packs;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering; using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Tests.Rendering.Packs; namespace AcDream.App.Tests.Rendering.Packs;
/// <summary> /// <summary>
/// Campaign VM VM6: <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/> /// Campaign VM VM6: <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/>
/// (the exact per-day-group mean/gust lookup) and /// (the exact per-weather-kind mean/gust lookup, keyed by the DAT-classified
/// <see cref="RenderPackAtmospherePolicyEvaluation.EaseTowardTarget"/> (the /// <see cref="WeatherKind"/> — corrected in the fix round from the raw
/// smoothing step that keeps a day-group change from snapping). /// <c>activeDayGroup</c> index, which carries no weather meaning by itself)
/// and <see cref="RenderPackAtmospherePolicyEvaluation.EaseTowardTarget"/>
/// (the smoothing step that keeps a weather change from snapping).
/// </summary> /// </summary>
public sealed class RenderPackAtmospherePolicyEvaluationTests public sealed class RenderPackAtmospherePolicyEvaluationTests
{ {
[Fact] private static readonly FoliageWindWeatherPoint[] FiveKindTable =
public void FoliageWindLooksUpExactDayGroupMatchOnly() [
new("Clear", 0.25, 0.15),
new("Overcast", 0.60, 0.35),
new("Rain", 0.85, 0.60),
new("Snow", 0.35, 0.20),
new("Storm", 1.00, 0.75),
];
[Theory]
[InlineData(WeatherKind.Clear, 0.25f, 0.15f)]
[InlineData(WeatherKind.Overcast, 0.60f, 0.35f)]
[InlineData(WeatherKind.Rain, 0.85f, 0.60f)]
[InlineData(WeatherKind.Snow, 0.35f, 0.20f)]
[InlineData(WeatherKind.Storm, 1.00f, 0.75f)]
public void FoliageWindResolvesEachOfTheFiveDeclaredWeatherKinds(
WeatherKind kind,
float expectedMean,
float expectedGust)
{ {
FoliageWindDayGroupPoint[] table = (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(
[ FiveKindTable,
new(0, 0.25, 0.15), kind);
new(1, 0.45, 0.30),
new(2, 0.60, 0.35),
new(3, 0.85, 0.60),
];
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 3); Assert.Equal(expectedMean, mean);
Assert.Equal(expectedGust, gust);
Assert.Equal(0.85f, mean);
Assert.Equal(0.60f, gust);
} }
[Fact] [Fact]
public void FoliageWindReturnsZeroForADayGroupAbsentFromTheTable() public void FoliageWindFallsBackToTheClearRowForAnUnlistedKind()
{ {
FoliageWindDayGroupPoint[] table = [new(0, 0.25, 0.15)]; // A table that only declares Clear and Storm — a kind absent from
// the table (Overcast here) falls back to the declared Clear row,
// not to (0, 0), since an unresolved weather kind is closer to "no
// weather data" than to "assume it's calm."
FoliageWindWeatherPoint[] table =
[
new("Clear", 0.25, 0.15),
new("Storm", 1.00, 0.75),
];
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 99); (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(
table,
WeatherKind.Overcast);
Assert.Equal(0.25f, mean);
Assert.Equal(0.15f, gust);
}
[Fact]
public void FoliageWindReturnsZeroWhenNeitherTheKindNorClearIsDeclared()
{
FoliageWindWeatherPoint[] table = [new("Storm", 1.00, 0.75)];
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(
table,
WeatherKind.Overcast);
Assert.Equal(0f, mean); Assert.Equal(0f, mean);
Assert.Equal(0f, gust); Assert.Equal(0f, gust);
@ -42,26 +79,34 @@ public sealed class RenderPackAtmospherePolicyEvaluationTests
[Fact] [Fact]
public void FoliageWindReturnsZeroForANullTable() public void FoliageWindReturnsZeroForANullTable()
{ {
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(null, 0); (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(
null,
WeatherKind.Clear);
Assert.Equal(0f, mean); Assert.Equal(0f, mean);
Assert.Equal(0f, gust); Assert.Equal(0f, gust);
} }
[Fact] [Fact]
public void FoliageWindDoesNotInterpolateBetweenDayGroupIndices() public void FoliageWindMatchesByExactNameNotSubstringOrCase()
{ {
// Day-group ids are not ordered by "how windy" — index 1 sitting // "Rain" must not match "Rainy" or any case variant — the lookup is
// between 0 and 2 in the table must not produce a value between // an ordinal exact match against the WeatherKind member name.
// their mean/gust. This asserts the lookup is an exact match, not FoliageWindWeatherPoint[] table =
// continuous interpolation across the index axis. [
FoliageWindDayGroupPoint[] table = [new(0, 0.0, 0.0), new(5, 1.0, 1.0)]; new("Rainy", 0.99, 0.99),
new("rain", 0.99, 0.99),
new("Clear", 0.10, 0.05),
];
(float meanAtUnlistedMidpoint, float gustAtUnlistedMidpoint) = (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(
RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 2); table,
WeatherKind.Rain);
Assert.Equal(0f, meanAtUnlistedMidpoint); // Neither "Rainy" nor "rain" matches WeatherKind.Rain.ToString()
Assert.Equal(0f, gustAtUnlistedMidpoint); // ("Rain", exact case) — falls back to the declared Clear row.
Assert.Equal(0.10f, mean);
Assert.Equal(0.05f, gust);
} }
[Fact] [Fact]

View file

@ -39,6 +39,104 @@ public sealed class RenderPackSpirvValidatorTests
result.Reason); result.Reason);
} }
// Campaign VM VM6 fix round: FoliageWindByWeather is keyed by the
// DAT-classified AcDream.Core.World.WeatherKind name (exact,
// case-sensitive), not the raw activeDayGroup index. An unknown name or
// a repeated kind must be rejected outright.
[Fact]
public void FoliageWindByWeatherRejectsAnUnknownWeatherKindName()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
descriptor = descriptor with
{
AtmospherePolicy = descriptor.AtmospherePolicy! with
{
FoliageWindByWeather =
[
new FoliageWindWeatherPoint("Cloudy", 0.45, 0.30),
],
},
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.False(result.Success);
Assert.Contains("unknown foliage-wind weather", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void FoliageWindByWeatherRejectsANonExactCaseWeatherKindName()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
descriptor = descriptor with
{
AtmospherePolicy = descriptor.AtmospherePolicy! with
{
FoliageWindByWeather = [new FoliageWindWeatherPoint("clear", 0.25, 0.15)],
},
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.False(result.Success);
Assert.Contains("unknown foliage-wind weather", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void FoliageWindByWeatherRejectsADuplicateWeatherKind()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
descriptor = descriptor with
{
AtmospherePolicy = descriptor.AtmospherePolicy! with
{
FoliageWindByWeather =
[
new FoliageWindWeatherPoint("Clear", 0.25, 0.15),
new FoliageWindWeatherPoint("Clear", 0.30, 0.20),
],
},
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.False(result.Success);
Assert.Contains("more than once", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void FoliageWindByWeatherAcceptsTheFiveDeclaredKindsOnce()
{
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
descriptor = descriptor with
{
AtmospherePolicy = descriptor.AtmospherePolicy! with
{
FoliageWindByWeather =
[
new FoliageWindWeatherPoint("Clear", 0.25, 0.15),
new FoliageWindWeatherPoint("Overcast", 0.60, 0.35),
new FoliageWindWeatherPoint("Rain", 0.85, 0.60),
new FoliageWindWeatherPoint("Snow", 0.35, 0.20),
new FoliageWindWeatherPoint("Storm", 1.00, 0.75),
],
},
};
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
descriptor,
RenderPackHostCapabilities.Conformance);
Assert.True(result.Success, result.Reason);
}
[Fact] [Fact]
public void DirectionalShadowDepthRejectsSunDirectionAlias() public void DirectionalShadowDepthRejectsSunDirectionAlias()
{ {