diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7b1f54ec..49ab8269 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| @@ -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-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-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) | --- diff --git a/docs/plans/2026-08-22-visualmaster-campaign.md b/docs/plans/2026-08-22-visualmaster-campaign.md index a48293a4..9162e3c3 100644 --- a/docs/plans/2026-08-22-visualmaster-campaign.md +++ b/docs/plans/2026-08-22-visualmaster-campaign.md @@ -474,18 +474,26 @@ direction to read, and the register row says so. ### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding) -Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the exact -design above. Settings (`BuiltInAtmosphericRenderPack.Settings()`): -`wind-enabled` (bool, default on), `wind-strength` (0–2×, default 1.0), -`wind-direction-degrees` (0–360°, default 225 — no authored retail wind -direction exists to read), `wind-lean-metres` (default 0.25), -`wind-branch-metres` (default 0.15), `wind-flutter-metres` (default 0.05, -forced to 0 on the Low preset), `wind-canopy-height-metres` (default 8). The -Clear/Cloudy/Overcast/Rainy mean/gust rows live in -`AtmospherePolicyDeclaration.FoliageWindByDayGroup`, keyed by the SAME -day-group index convention `ActiveDayGroupMultipliers` already established -two lines above it in `AtmosphericPolicy()` (0 Clear / 1 Cloudy / 2 Overcast; -index 3 Rainy is new). Classification bits live in +Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the +design above; a same-day fix-round commit corrected the weather-table key. +Settings (`BuiltInAtmosphericRenderPack.Settings()`): `wind-enabled` (bool, +default on), `wind-strength` (0–2×, default 1.0), `wind-direction-degrees` +(0–360°, default 225 — no authored retail wind direction exists to read), +`wind-lean-metres` (default 0.25), `wind-branch-metres` (default 0.15), +`wind-flutter-metres` (default 0.05, forced to 0 on the Low preset), +`wind-canopy-height-metres` (default 8). The mean/gust rows live in +`AtmospherePolicyDeclaration.FoliageWindByWeather`, keyed by NAME +(`FoliageWindWeatherPoint.WeatherKind`, an exact ordinal match against +`AcDream.Core.World.WeatherKind`'s member names — `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), +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`) cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in `WbDrawDispatcher.ClassifyBatches` (world receiver) and diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index f0ff1d4f..36c116e1 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -420,7 +420,7 @@ internal sealed class AtmosphericPostProcessGraph : worldMeshes.FoliageWindExclusions = _foliageWindExclusions; bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell; AtmosphericFrameBufferBinding shadowAtmosphericFrame = - BuildShadowAtmosphericFrameBinding(frame, activeDayGroup, isOutdoor); + BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor); var input = new DirectionalSunShadowRenderInput( environment, world.Camera.Camera.View, @@ -480,12 +480,12 @@ internal sealed class AtmosphericPostProcessGraph : /// private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding( IGpuFrame frame, - int activeDayGroup, + AcDream.Core.World.WeatherKind weather, bool isOutdoor) { (Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind( frame.Serial, - activeDayGroup, + weather, isOutdoor); GpuRingAllocation allocation = frame.AllocateRing( AtmosphericFrameUniforms.SizeInBytes, @@ -530,13 +530,15 @@ internal sealed class AtmosphericPostProcessGraph : /// for its own animation clock — replaces it when set, which is every /// run but a differential/offline gate's. /// - /// The smoothing. The per-day-group (mean, gust) target - /// from - /// eases toward its target using an exponential moving average over - /// (10 s, - /// the same authored weather-transition constant retail-parity work - /// already established) — so a day-group change never snaps. This state - /// keeps evolving even while indoors or disabled; wind-enabled + /// The smoothing. The per-weather-kind (mean, gust) target + /// from — + /// keyed by the DAT-classified , + /// not the raw day-group index, which carries no weather meaning by + /// itself — eases toward its target using an exponential moving average + /// over + /// (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; wind-enabled /// off or !isOutdoor instead multiplies the OUTPUT by an exact /// zero gate (never an asymptotic approach), so a settings toggle or a /// cell transition reads exactly zero on the very next frame, and @@ -545,7 +547,7 @@ internal sealed class AtmosphericPostProcessGraph : /// private (Vector4 ClockWind, Vector4 WindAmplitude) ResolveFoliageWind( long frameSerial, - int activeDayGroup, + AcDream.Core.World.WeatherKind weather, bool isOutdoor) { float clockSeconds = _windClockSecondsOverride @@ -558,8 +560,8 @@ internal sealed class AtmosphericPostProcessGraph : 1f); (float targetMean, float targetGust) = RenderPackAtmospherePolicyEvaluation .FoliageWind( - Descriptor.AtmospherePolicy?.FoliageWindByDayGroup, - activeDayGroup); + Descriptor.AtmospherePolicy?.FoliageWindByWeather, + weather); targetMean *= _foliageWind.Strength; targetGust *= _foliageWind.Strength; _windMean = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget( @@ -689,7 +691,7 @@ internal sealed class AtmosphericPostProcessGraph : : 0f; (Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind( frame.Serial, - inputs.ActiveDayGroup, + inputs.Weather, inputs.IsOutdoor); var frameUniforms = new AtmosphericFrameUniforms( new Vector4( diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs index 055df9ae..592f89da 100644 --- a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs +++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs @@ -369,21 +369,25 @@ internal static class BuiltInAtmosphericRenderPack new SunElevationResponsePoint(70, 0), new SunElevationResponsePoint(90, 0), ], - // Campaign VM VM6: the plan's Clear/Cloudy/Overcast/Rainy rows, - // keyed by the SAME activeDayGroup index convention this pack - // already established two lines above for ActiveDayGroupMultipliers - // (0 brightest/clearest ... 2 dimmest). That table only needed - // three rows; foliage wind needs a fourth (Rainy), so index 3 is - // new here. Dereth's DAT declares many more named day groups than - // these four categories distinguish — an index absent from this - // table (including every index above 3) gets zero wind rather - // than guessing a category. - FoliageWindByDayGroup = + // Campaign VM VM6, corrected in the fix round: keyed by the + // DAT-classified AcDream.Core.World.WeatherKind, not the raw + // activeDayGroup index — WeatherState.cs's existing name + // classification (e.g. "Sunny" -> Clear, a name containing + // "fog"/"cloud"/"overcast"/"dark"/"storm"/"snow"/"rain" -> the + // matching kind) already turns a day group's authored DAT name + // into one of these five real weather kinds; that fact was + // already threaded through AtmosphericFrameInputs.Weather / + // uAtmosphereWeather.x, so this table reuses it instead of + // 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 FoliageWindDayGroupPoint(1, 0.45, 0.30), // Cloudy - new FoliageWindDayGroupPoint(2, 0.60, 0.35), // Overcast - new FoliageWindDayGroupPoint(3, 0.85, 0.60), // Rainy + 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), ], }; diff --git a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs index 533cb8bc..9125d2f6 100644 --- a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs +++ b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs @@ -1,3 +1,4 @@ +using AcDream.Core.World; using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.App.Rendering.Packs; @@ -45,26 +46,40 @@ internal static class RenderPackAtmospherePolicyEvaluation fallback); /// - /// Campaign VM VM6: exact day-group lookup for foliage wind — the same - /// shape as 's exact-match pattern - /// (day groups are not ordered by "how windy," so there is nothing to - /// interpolate across them). A day group absent from the table returns - /// (0, 0) — no wind is the safe default for an unclassified day group, - /// not full wind. The caller is responsible for smoothing the resolved - /// target over time; this method is a pure, stateless lookup. + /// Campaign VM VM6, corrected in the fix round: exact weather-kind + /// lookup for foliage wind, keyed by the DAT-classified + /// the frame already carries + /// (AtmosphericFrameInputs.Weather / uAtmosphereWeather.x) + /// — not the raw activeDayGroup index, which carries no weather + /// meaning by itself. Matches by weather.ToString() (ordinal) + /// against each declared + /// 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. /// internal static (float Mean, float Gust) FoliageWind( - IReadOnlyList? points, - int activeDayGroup) + IReadOnlyList? points, + WeatherKind weather) { if (points is null) 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); + 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); } /// diff --git a/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs index c16e67e1..ffae24b3 100644 --- a/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs +++ b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs @@ -1482,6 +1482,39 @@ internal static class RenderPackValidator || !IsFiniteNonNegative(value.Multiplier)) 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(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(); RenderPackValidationResult ValidateCurve( diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs index d261948b..d48ca9e1 100644 --- a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs @@ -387,12 +387,25 @@ public sealed record ActiveDayGroupMultiplier( double Multiplier); /// -/// Explicit mapping from an authored AC day group to the foliage-wind mean -/// and gust strength for that group (both in the declared [0,1] range before -/// the pack's global wind-strength setting scales them). Campaign VM VM6. +/// Explicit mapping from a DAT-classified weather kind to the foliage-wind +/// mean and gust strength for that weather (both in the declared [0,1] range +/// before the pack's global wind-strength setting scales them). Campaign VM +/// VM6, corrected in the fix round: the day-group's raw activeDayGroup +/// INDEX carries no weather meaning by itself — WeatherState.cs'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 AtmosphericFrameInputs.Weather / uAtmosphereWeather.x. +/// This is the same fact, keyed correctly instead of by index. /// -public sealed record FoliageWindDayGroupPoint( - int ActiveDayGroup, +/// +/// The exact member name of AcDream.Core.World.WeatherKind — one of +/// "Clear", "Overcast", "Rain", "Snow", +/// "Storm". A plain string because this project (Plugin.Abstractions) +/// is BCL-only and cannot reference the Core enum; the host converts with +/// WeatherKind.ToString() (ordinal) when resolving. +/// +public sealed record FoliageWindWeatherPoint( + string WeatherKind, double Mean, double Gust); @@ -424,18 +437,18 @@ public sealed record AtmospherePolicyDeclaration( { get; init; } = []; /// - /// Campaign VM VM6: exact per-day-group foliage-wind mean/gust targets, - /// looked up by the same categorical activeDayGroup id - /// already keys on (an exact - /// match, not an interpolation across day-group ids — day groups are not - /// ordered by "how windy"). The host smooths the resolved (mean, gust) - /// target toward whatever this table returns over time using the - /// existing weather delta-seconds clock, so a day-group change never - /// snaps. A day group absent from this table gets zero wind. A pack - /// using foliage wind should declare an entry for every day group it - /// cares about distinguishing. + /// Campaign VM VM6: exact per-weather-kind foliage-wind mean/gust + /// targets, looked up by the DAT-classified + /// AcDream.Core.World.WeatherKind (an exact match, not an + /// interpolation — the five kinds are not ordered by "how windy"). The + /// host smooths the resolved (mean, gust) target toward whatever this + /// table returns over time using the existing weather delta-seconds + /// clock, so a weather change never snaps. A kind absent from this table + /// falls back to the declared Clear row, then to (0, 0) if Clear itself + /// is undeclared. Each declared + /// must be one of the five real kind names and must appear at most once. /// - public IReadOnlyList FoliageWindByDayGroup + public IReadOnlyList FoliageWindByWeather { get; init; } = []; /// diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs index 7ac82c34..1b783c87 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs @@ -1228,11 +1228,12 @@ public sealed class AtmosphericPostProcessGraphTests var device = new RecordingGpuDevice(); using var graph = Graph(device, "medium", windClockSecondsOverride: 12f); graph.PrepareWorldTarget(640, 480, 1); - // Rainy (index 3 in the built-in table) is the highest declared - // mean/gust — the exact target does not matter here, only that the - // gate still zeroes the output despite a nonzero smoothed target. - AtmosphericFrameInputs indoors = Inputs(640, 480, activeDayGroup: 3) with + // Storm is the highest declared mean/gust — the exact target does + // not matter here, only that the gate still zeroes the output + // despite a nonzero smoothed target. + AtmosphericFrameInputs indoors = Inputs(640, 480) with { + Weather = WeatherKind.Storm, IsOutdoor = false, }; @@ -1252,7 +1253,7 @@ public sealed class AtmosphericPostProcessGraphTests windClockSecondsOverride: 12f, userSettingOverrides: new Dictionary { ["wind-enabled"] = "false" }); 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); @@ -1290,7 +1291,7 @@ public sealed class AtmosphericPostProcessGraphTests IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1); using IGpuFrame frame = device.BeginFrame(); RecordWorldPass(frame, world); - AtmosphericFrameInputs inputs = Inputs(640, 480, activeDayGroup: 3); + AtmosphericFrameInputs inputs = Inputs(640, 480) with { Weather = WeatherKind.Storm }; graph.RenderPostProcess(frame, in inputs); AtmosphericFrameUniforms first = ReadLastFrameBlock(device); diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs index 165a39b7..3b3f0142 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs @@ -1,39 +1,76 @@ using AcDream.App.Rendering.Packs; +using AcDream.Core.World; using AcDream.Plugin.Abstractions.Rendering; namespace AcDream.App.Tests.Rendering.Packs; /// /// Campaign VM VM6: -/// (the exact per-day-group mean/gust lookup) and -/// (the -/// smoothing step that keeps a day-group change from snapping). +/// (the exact per-weather-kind mean/gust lookup, keyed by the DAT-classified +/// — corrected in the fix round from the raw +/// activeDayGroup index, which carries no weather meaning by itself) +/// and +/// (the smoothing step that keeps a weather change from snapping). /// public sealed class RenderPackAtmospherePolicyEvaluationTests { - [Fact] - public void FoliageWindLooksUpExactDayGroupMatchOnly() + private static readonly FoliageWindWeatherPoint[] FiveKindTable = + [ + 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 = - [ - new(0, 0.25, 0.15), - new(1, 0.45, 0.30), - new(2, 0.60, 0.35), - new(3, 0.85, 0.60), - ]; + (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind( + FiveKindTable, + kind); - (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 3); - - Assert.Equal(0.85f, mean); - Assert.Equal(0.60f, gust); + Assert.Equal(expectedMean, mean); + Assert.Equal(expectedGust, gust); } [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, gust); @@ -42,26 +79,34 @@ public sealed class RenderPackAtmospherePolicyEvaluationTests [Fact] 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, gust); } [Fact] - public void FoliageWindDoesNotInterpolateBetweenDayGroupIndices() + public void FoliageWindMatchesByExactNameNotSubstringOrCase() { - // Day-group ids are not ordered by "how windy" — index 1 sitting - // between 0 and 2 in the table must not produce a value between - // their mean/gust. This asserts the lookup is an exact match, not - // continuous interpolation across the index axis. - FoliageWindDayGroupPoint[] table = [new(0, 0.0, 0.0), new(5, 1.0, 1.0)]; + // "Rain" must not match "Rainy" or any case variant — the lookup is + // an ordinal exact match against the WeatherKind member name. + FoliageWindWeatherPoint[] table = + [ + new("Rainy", 0.99, 0.99), + new("rain", 0.99, 0.99), + new("Clear", 0.10, 0.05), + ]; - (float meanAtUnlistedMidpoint, float gustAtUnlistedMidpoint) = - RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 2); + (float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind( + table, + WeatherKind.Rain); - Assert.Equal(0f, meanAtUnlistedMidpoint); - Assert.Equal(0f, gustAtUnlistedMidpoint); + // Neither "Rainy" nor "rain" matches WeatherKind.Rain.ToString() + // ("Rain", exact case) — falls back to the declared Clear row. + Assert.Equal(0.10f, mean); + Assert.Equal(0.05f, gust); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs index 83c21c43..422e6a55 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackSpirvValidatorTests.cs @@ -39,6 +39,104 @@ public sealed class RenderPackSpirvValidatorTests 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] public void DirectionalShadowDepthRejectsSunDirectionAlias() {