acdream/docs/plans/2026-08-22-visualmaster-campaign.md
Erik eec9553582 fix(render): foliage wind no longer depends on the directional-shadow gate (Campaign VM VM6 review 4)
The reviewer's offline pixel apparatus found a real design defect, not a
test artefact: foliage wind was welded to "directional shadows rendered
this frame." Evidence: offline High preset, sun-shadow-strength=0,
wind-strength 2 + lean/branch 1 m — wind-on vs wind-off at the same
pinned clock differed by only 49-65 px, inside the apparatus's own 22 px
run-to-run noise floor (no measurable motion). A CPU probe independently
confirmed ResolveFoliageWind was correct (first advance snaps to Clear
0.25/0.15, gate 1, one graph) — the correct uniform never reached the
world pass.

Root cause: DirectionalSunShadowRenderer.Render's two early-out paths
(!environment.ShouldRender, ResidentWindowUnavailable) left
_currentFrameBinding at its pure Disabled (no-buffer) default.
WbDrawDispatcher.PipelinesFor and TerrainModernRenderer's matching
selection logic only choose the atmospheric receiver pipeline
(mesh_atmospheric, the only pipeline that #includes foliage_wind.glsl)
when TryGetCurrentFrameBinding returns true; with no buffer it always
returned false, so the world pass silently fell back to the plain
mesh_modern pipeline, which has no wind code at all. Because the shadow
gate is ActiveDayGroupMultiplier = dayGroupPolicy x elevationResponse x
strength, this killed wind every night (elevation response -> 0), at
user sun-shadow-strength 0, and under the portal/login cover.

Fix (decouple, not patch): DirectionalShadowFrameBinding gained
IsBindableFor ("a real current-frame allocation exists") separate from
IsValidFor ("...and it is Enabled with real shadow content" -- kept
exactly as VolumetricShaftRenderer's own gate needs it).
TryGetCurrentFrameBinding now returns IsBindableFor. When the built-in
pack supplies an AtmosphericFrame binding (declared packs never do, so
their receiver shaders -- which never declare set 3 binding 5 -- are
unaffected), Render's two early-out paths call a new
PublishDisabledReceiverBinding: it allocates one real ring slice and
writes a DISABLED DirectionalShadowUniforms block -- every matrix
Identity, every control/bias term zero, TextureAndFlags all zero (bit 0
clear is exactly what directional_shadow_receiver.glsl's
acdreamDirectionalShadowVisibility already reads as "no shadow, full
visibility" via its existing early return 1.0), and a unit light
direction (0,0,1) so a fragment shader's normalize() can never produce
NaN. BindDirectionalShadowReceiver and TerrainModernRenderer's
shadow-buffer bind now check Buffer is not null instead of Enabled, so
the disabled block actually gets bound once it is selected.

PublishDisabledReceiverBinding is internal (not private) specifically so
it is testable without standing up a real WbDrawDispatcher/
TerrainModernRenderer pair -- no test in this suite constructs either.
New tests: (a)/(b) PublishDisabledReceiverBinding is bindable-not-valid
with a bound AtmosphericFrame and a genuine no-op with an unbound one;
(c) BindDirectionalShadowReceiver emits both UniformDirectionalShadow and
UniformAtmosphericFrame binds for a disabled binding; (d)
VolumetricShaftRenderer's gate still reports NoCurrentDirectionalShadow
for a disabled binding. ShouldSelectReceiverPipeline itself is untouched
and its existing tests (parametrized directly on bindingValid) remain
valid; no existing test asserted the old "disabled shadows -> plain
pipeline / no binding" behaviour in a way this fix invalidates -- every
existing caller either bypasses Render (calls RenderPrepared directly)
or uses a stale-serial binding IsBindableFor still correctly rejects.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,050/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered
solution: 15,278/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:29:57 +02:00

742 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign VM — VisualMaster
**Date:** 2026-08-22 · **Status:** PLANNED — awaiting the owner's goal
**Phase id:** **Campaign VM** — named by the project owner on 2026-08-22
**Branch:** `claude/git-sync-status-5fb1d2` (= main + Campaign AR, `c51b07ef`)
**Predecessor:** [Campaign AR](2026-08-21-atmospheric-rendering.md) and its
[independent review](../research/2026-08-22-campaign-ar-review.md)
**Scheduling:** owner-directed, like Campaign AR. Does not displace M4.
## Goal
Make Campaign AR **provably safe to merge** and then **finish the look**:
close every review finding with evidence rather than assertion, put the
Tier-1 post stack in linear light so its controls mean what they say, and add
the one cheap, high-impact enhancement still missing from the discussed
scope — **gentle, believable wind in Dereth's foliage**, driven by AC's own
authored weather, opt-in through the same render-pack contract, with the
default retail-faithful path untouched.
Campaign VM is shipped when both sides of the final matrix pass:
- **pack off:** pixel-identical to the pre-campaign build outside the two
intended parity changes (terrain normals, building detail), with production
CPU/GPU/allocation within the pre-campaign noise envelope; and
- **pack on:** linear-light post stack, registered-or-removed detail fade,
moving foliage that the owner judges "real, not a screensaver", shadows
that move with the leaves, and all of it inside Campaign AR's declared
budgets.
## Operating model
The launcher/AR cadence, unchanged:
- **Fable** plans, verifies claims against source/decomp, writes the
closeouts, and drives the work order. Nothing in a closeout may claim more
than its artifact shows — every "passes" names the command and the lane.
- **Sonnet** implements each slice against the pinned contract below.
- **Opus** reviews every slice with two lenses (architectural / retail-
faithful) before its closeout; findings go through a fix round and a narrow
re-review.
- **The owner** is stopped for exactly three things: the cdb read on the
retail client (VM2), the visual gates (VM3, VM6, VM7), and the merge.
Rules binding on every slice: no workarounds (CLAUDE.md), no guessed
AC-specific constants — a number without a decomp/DAT anchor gets a register
row in the same commit — and the pack-off path is the oracle: any slice that
moves a pack-off pixel outside its declared mask is a bug, not a tuning.
## Lesson carried into every slice — a branch that exists is not a branch that runs
VM2 found that Campaign AR's #226 port, the 2026-08-21 findings doc it was
briefed from, and the AR review's "probably" were all describing the
**fallback** branch of retail's detail pass — the `stage == 0` framebuffer
blend that retail only takes when the adapter cannot advertise
`D3DTEXOPCAPS_PREMODULATE`. Real hardware (the owner's AMD, and anything
modern) takes the single-pass texture-stage branch, which computes a
different formula with the opposite sign of effect (darkens, not brightens).
The decomp was read correctly three times; what nobody did until VM2 was
spend two minutes reading the capability on a live client.
Binding rule for this campaign and after: **when retail gates a mechanism
behind a capability, preference, or `trysinglepass`-style switch, the port
is not done until the gate's live value is recorded** — a cdb read on the
PDB-paired client, cited in the port's note with the binary GUID. "Grep
named → decompile → port" answers what the code *says*; only the live read
answers what it *does* on the hardware the game actually ran on. A guess
about driver caps ("rarely advertised") is not evidence and must not appear
in a review verdict.
Corollary for brightness questions: the owner's live "too bright" (exposure
1.0 → 0.80) was the pack-on gamma-space tonemap (F4, VM3), not this. The
detail overlay's fallback brightening is +3 % on building shells only; the
single-pass path darkens by ~10 %. Neither explains a scene-wide level.
## Slice ledger
| Slice | Closes | Gate |
|---|---|---|
| VM0 | F1 — default-path invariance | **CLOSED 2026-08-22 — PASS**; [report](../research/2026-08-22-vm0-default-path-invariance.md) |
| VM1 | F2/F3 — #226 single-pass re-port + fade removal | **CLOSED 2026-08-22**`05970306`, `388457a7`, `ae651312`, closeout; Opus APPROVE |
| VM2 | F3 — which retail detail path ran | **CLOSED 2026-08-22** — single-pass; see [cdb note](../research/2026-08-22-vm2-retail-detail-path-cdb.md) |
| VM3 | F4 — linear-light post stack | **CODE-COMPLETE 2026-08-22**`87677f9c`, `51178f7c`, closeout; Opus APPROVE; **owner visual gate OWED** (brief below) |
| VM4 | F5 — overclaiming docs, incl. the reviewer's own | **CLOSED 2026-08-22** |
| VM5 | F8 — volumetric banding; F7 filed | **CLOSED 2026-08-22** (#421) |
| VM6 | Foliage wind | automated + **owner visual** |
| VM7 | Closeout: full gates, register, roadmap, merge | **owner** |
Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (code-complete, gate owed) → VM6 → VM7. VM0 goes first because everything after it is measured
against the baseline it establishes.
---
## VM0 — Default-path invariance (F1)
**Why:** the only "pre-campaign oracle" is a 2×2 synthetic, one-draw
recording-device fixture. `WbDrawDispatcher.Rhi.cs` was refactored underneath
the default path. Nobody has compared real pack-off pixels or production
performance against `6c79d35c`.
**Implementation**
1. Build `6c79d35c` in a throwaway worktree (Release). Capture with
`tools/run-offline-pixel-gate.ps1 -Out artifacts/vm0/base` — the tool's
own documented baseline mode.
2. Capture HEAD pack-off twice with the same tool and camera set:
`BuildingDetailTextures=false` (isolates the A2 normal change) and
`=true` (the shipped default).
3. Extend the tool's compare step with a **terrain mask** (same mechanism as
the existing `sky-mask.png`): pixels whose depth/material belong to
`terrain_modern` are excluded from the identity assertion and reported
separately.
4. Assert: HEAD(detail=false) vs base — **zero** differing pixels outside the
terrain mask; inside the mask, differences are reported as a histogram
(expected: low-amplitude shading deltas, no structural change).
HEAD(detail=true) vs HEAD(detail=false) — differences confined to
building/EnvCell surfaces (second mask from the detail replay's own
command set).
5. Production performance A/B, **no automation observer, no validation
layers, uncapped Release**, at the CLAUDE.md production-profile camera and
at pinned dense Arwic: base vs HEAD pack-off. Record CPU/GPU p50/p95/p99,
FPS, `alloc_kb p50`, GC counts. The pre-campaign references are
519.7 FPS / 1.869 ms CPU p50 (profile) and ~3.0/4.9 ms CPU p50/p95 (dense
Caul, digest §render). Deltas must sit inside run-to-run noise; any
allocation growth on the pack-off path is a regression to fix in this
slice, not to file.
6. Add the masked comparison as a repeatable tool mode
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
**OUTCOME (2026-08-22): PASS.** Connected, visible-window, isolated-config captures: base+normals vs HEAD-off has zero strong static differences outside the animated lifestone at Holtburg and the open field; production CPU is 13% cheaper and allocation 25x lower on HEAD's pack-off path (no observer, uncapped Release). Three config traps (isolated FOV, the real Roaming settings still selecting the pack, minimized-window throttling) are recorded in the report. Replaced the planned terrain mask with a base+normals baseline (no mask needed).
**Acceptance:** the two pixel assertions in step 4 hold; the perf A/B shows
no default-path regression; the Fable report records the exact commands,
commits and artifact paths. If a regression is found, it is fixed here and
the report names the cause — no "accepted difference" without a diff that
explains it.
## VM1 — Remove the invented detail fade (F2)
**Why:** `RetailDetailTextureContract.FullDetailDistanceMetres = 10` /
`ZeroDetailDistanceMetres = 50` have no retail anchor. Retail's
`DrawBuilding`/`DrawEnvCell`/`RenderMeshSubset`/`SetDetailSurfaceInternal`
carry no distance term; attenuation is the LINEAR mip chain.
**Decision (Fable, retail-faithful rule):** remove the ramp. **Amended by VM2:** VM1 also re-ports the blend to the single-pass math (see the VM2 outcome) — the fade removal and the blend correction land together, with the `RetailDetailTextureContract` helper and tests rewritten around `Expected(base, detail, diffuseAlpha)`. Mip averaging
already converges the live category texture to its 1.033 mean factor.
**Implementation**
- Delete the two constants and `vDetailFade` from `mesh_detail.vert/.frag`
(`mesh_detail.vert:83`); the fragment outputs `applyFog(detail.rgb)` with alpha `detail.a * instanceOpacity` — fogged so the two-draw result collapses to retail's fog-after-combine pixel (VM1 review fix).
- Confirm the detail texture is uploaded with a full mip chain and sampled
LINEAR/LINEAR/LINEAR, WRAP — that *is* retail's attenuation; test it.
- Rename `RetailDetailTextureContract` members so nothing un-anchored is
labelled retail; keep the blend-factor helper and its tests.
- Update the #226 pseudocode note: strike the "10 m / 50 m" lines, add the
decomp citations above, and state that attenuation is mip-driven.
- If the owner later wants a ramp back, it returns as a **pack setting**
(enhancement), never on the default path.
**OUTCOME (2026-08-22): CLOSED.** Four commits: the single-pass re-port + fade removal (`05970306`); the interior shell/detail passes binding their own instance opacity — a pre-campaign stale-slot read (`388457a7`); the Opus fix round — the detail contribution is fogged so the two-draw result collapses to retail's fog-after-combine pixel, mip/sampler test, doc caveats (`ae651312`); and the closeout (AP-232 for the translucent-subset blend weight, the sampler test pinned to the production constant). Measured on Holtburg buildings: predicted +2.2/+0.66/+0.16 levels vs measured +2.17/+0.57/+0.16 (`artifacts/vm1`, grass/sky controls 0.00). Detail-on costs +0.3-0.5 ms CPU / +0.1 ms GPU at dense Arwic, still under the pre-campaign baseline. Opus dual-lens review: APPROVE WITH FIXES -> fix round -> narrow re-review APPROVE.
**Acceptance:** no reference to a distance fade remains in src or the note;
VM0's masked comparison re-run shows the only change is on detail surfaces;
at >50 m a building reads the same as at 60 m (mips), not a hard step.
## VM2 — Which detail path did retail hardware run? (F3, owner-gated)
**Why:** the port is of the two-pass framebuffer fallback. With
`m_caps.bCanDoSinglePassDetailing`, retail used stage-1 texture ops
(`PREMODULATE` + `BLENDCURRENTALPHA` — a lerp). The cap needs
`D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6), which consumer drivers rarely
exposed — probable, not proven.
**Implementation:** one cdb script (`tools/cdb/vm2-detail-caps.cdb`): attach
to the PDB-paired `acclient.exe`, `dt acclient!RenderDevice::render_device`
`m_caps.bCanDoSinglePassDetailing`, `m_caps.bTexOpDotProduct3`, and the raw
`m_D3DCaps.TextureOpCaps`; `qd`. Record the answer in the #226 note and the
review doc.
- `bCanDoSinglePassDetailing == 0` → the port is the path players saw; close.
- `== 1` → file the single-pass stage math as the correct target, register
the current blend as a bounded divergence, and schedule the re-port as its
own slice (it is a fragment-shader change only).
**Acceptance:** the value is recorded with the binary GUID and the date.
**OUTCOME (2026-08-22): `bCanDoSinglePassDetailing = 1`, `trysinglepass = 1`.** Retail on the owner's GPU runs the single-pass stage path: `lerp(base·diffuse, detail.rgb, detail.a·diffuse.a)` — a mild darkening, not the fallback's brightening. The #226 port targets the wrong path and is re-ported inside VM1 (blend `SRCALPHA+INVSRCALPHA`, output `detail.rgb, detail.a·diffuseAlpha`, neutral at `detail.a == 0`). Also read: `LandscapeDetailTextures = 0` is a real separate preference. Evidence and the exact stage math: [2026-08-22-vm2-retail-detail-path-cdb.md](../research/2026-08-22-vm2-retail-detail-path-cdb.md).
## VM3 — Linear-light post stack (F4)
**Why:** bloom threshold, ACES, Rec.709 luma, saturation and the 0.5
contrast pivot all assume linear light; they receive gamma-encoded retail
colours and the output is never re-encoded. `exposure = 0.80` is the
compensation.
**Design**
- The main-world intermediate stays gamma-encoded `Rgba16Float` — the world
pass and its alpha blending are retail's and must not change.
- Every pack read of world colour decodes once: `acdreamDecode(c) = pow(c, 2.2)`
(a named function in `atmospheric_common.glsl`; 2.2 is the retail-era
display assumption, documented as such — not sRGB piecewise, which would
imply a precision the source never had).
- Bloom chain, sun-ray composite and volumetric composite operate in linear.
- `atmospheric_filmic.frag`: exposure → ACES → grade → vignette in linear,
then `acdreamEncode(c) = pow(c, 1/2.2)` to the UNORM swapchain.
- Defaults re-tuned so the **neutral** preset reproduces the pack-off image
within the VM0 tolerance when every effect is at its neutral value
(exposure 1.0, bloom 0, tonemap mix 0, saturation 1, contrast 1,
vignette 0) — the existing "every effect can be set to neutral" acceptance
now actually holds numerically, and is asserted by a test.
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
re-derived in linear and presented for the visual gate. **Shipped truth
(not the pre-implementation guess above):** exposure stays at **0.80**
at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially
the same 0.6163 the owner called too bright under the old gamma-space
pipeline, so 1.0 reproduces the rejected look, not a corrected one. The
bloom **threshold stays 1.0** (a fixed point of both `pow(x,2.2)` and
`pow(x,1/2.2)` — 1.0 decodes and encodes to 1.0), while the **knee moves
0.45 → 0.73** (`AtmosphericPostProcessGraph.BloomKneeLinear`) and
**vignette-strength moves 0.12 → 0.245** (re-derived so the same accepted
12% corner darkening survives the encode step). See
`AtmosphericColorPipelineTests` for the pinned numbers behind each of
these.
Old-vs-new curve at the shipped exposure 0.80 (gamma input →
old-pipeline display value / new-pipeline display value; filmic
strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
applied directly to the gamma value versus `encode(acesFitted(0.80 *
decode(g)))`):
| gamma in | old display | new display |
|---:|---:|---:|
| 0.05 | 0.031 | 0.023 |
| 0.10 | 0.091 | 0.052 |
| 0.15 | 0.162 | 0.091 |
| 0.20 | 0.233 | 0.140 |
| 0.30 | 0.360 | 0.265 |
| 0.46 | 0.511 | 0.488 |
| 0.50 | 0.541 | 0.539 |
| 0.70 | 0.652 | 0.735 |
| 0.90 | 0.725 | 0.845 |
| 1.00 | 0.752 | 0.879 |
Shadows deepen slightly (0.050.30 gamma read darker), the crossover sits
near gamma 0.5 (old and new agree almost exactly there — expected, since
that is close to the 0.46 midtone the exposure was tuned against), and
highlights above roughly gamma 0.5 now read brighter instead of
compressing toward ACES's ~0.75 ceiling.
**Acceptance:** neutral preset ≡ pack-off within tolerance — proved by the
CPU mirror `AtmosphericColorPipeline` (`AtmosphericColorPipelineTests`,
half-an-8-bit-step identity over a 0..255 grey sweep) plus shader-source
pins (`AtmosphericPostProcessGraphTests`) guarding the decode/encode call
sites against silent removal. The real-frame masked capture **was run**
(retail vs High-with-every-effect-neutral, `artifacts/vm3`): 110,561 px at
`|Δ|=1` (float/pow round-trip noise, sub-visible) and 95 foliage-silhouette
pixels at `|Δ|≥5` (58 isolated, max 73 — cutout-edge rasterization between two
separate client runs; ambient motion is the alternative explanation), nothing
on any ground/building/water surface. Stage-1's luminance table re-capture is still **owed** at the
owner gate; owner visual gate: "same look as accepted, no clipping,
highlights roll off". Budget unchanged (two `pow` per pixel).
### VM3 owner visual gate — brief (read this, then play)
Launch normally (launcher or `ACDREAM_RETAIL_UI=1` env launch), Options →
Display → render pack **Atmospheric / High**. Every number below is measured,
not predicted; the sliders are live so you can tune while looking.
**What changed on screen, at the shipped defaults (exposure 0.80):**
| input tone (gamma) | before VM3 | after VM3 | Δ |
|---|---|---|---|
| deep shadow 0.10 | 0.091 | 0.052 | 43 % |
| shadow 0.20 | 0.233 | 0.140 | 40 % |
| mid-grey 0.46 | 0.511 | 0.488 | 5 % |
| 0.50 | 0.541 | 0.539 | 0 (crossover) |
| bright 0.70 | 0.652 | 0.735 | +13 % |
| highlight 0.90 | 0.725 | 0.845 | +16 % |
Midtones are where you left them; **shadows are deeper and highlights no
longer crushed** — that is the linear ACES toe/shoulder, not a bug. Offline
Holtburg hillside captures (`docs/research/evidence/vm3/`): mean luminance vs
pack-off **17 % at noon, 44 % at dusk** (codex's accepted look measured
8 % outdoors on a different scene). Vignette was re-derived (0.12 → 0.245) so
the corner darkening on screen is the same 12 % you accepted.
**Checklist (≈10 min):**
1. Noon outdoors, walk a shaded street: do shadows read as "deep" or as
"crushed"? If crushed → raise **Exposure** to 0.91.0 (mid-greys will
brighten ~10 %) or lower **Filmic strength** to 0.70.8 (lifts the toe,
keeps the shoulder).
2. Look at the sky near the sun and a bright roof: highlights should roll off
smoothly with no clipping (clip % measured 0.06, same as pack off).
3. Dusk (`/time` or wait): the 44 % is the effect of long shadows plus the
toe. Judge whether it is atmospheric or merely dark; the same two sliders
apply.
4. Corners: vignette should match the previous feel. If you had a saved
override of 0.12 it is now weaker (the default moved, overrides did not).
5. Switch the pack to **retail/off** and back: with every effect at its
neutral value the image is the pack-off image (measured: ≤1 LSB on 99.99 %
of pixels).
Say which slider values you settle on; they become the shipped defaults in
VM7. Known: #422 (1-in-8 heap-corruption exit after a pack-on run) is open
and is a VM7 gate item, not a VM3 one.
## VM4 — Truthful documents (F5)
**OUTCOME (2026-08-22): CLOSED.** Corrected in place, each with a dated
"VM4 correction" note: the AR plan's 2x2-oracle sentence and both
"zero skips" totals (now "under the hermetic lane filter"); the Stage-2
report's turning-hitch paragraph (observer tax, not product); the Track A
report's and the findings doc's `TerrainUtils.GetNormal` premise; the findings
doc's "retail brightens" section (fallback only; single-pass lerp is what runs),
its landscape-detail open question (`LandscapeDetailTextures = 0`, answered by
VM2), and its open question 5; the completion audit's retail-path row; and the
review's own F1/F2/F3/F5 headers. The VM0 production table is now the baseline
in the findings doc §5.
Correct, in the AR plan and its reports, the four sentences the review
named: the 2×2 oracle described as a production pin; the turning-hitch
conclusion drawn under the automation observer; the "zero skips" headline
(say "hermetic lanes"); and the Track A premise that `TerrainUtils.GetNormal`
produced a faceted look. Also correct the reviewer's own findings doc §4
(`2026-08-21-terrain-and-atmospheric-rendering-findings.md`): that function
orients scenery; the render normals were already smooth; A2 replaced
central-difference with retail's split-aware incident-face average.
Add the VM0 numbers as the new baseline table. Docs only; no code.
## VM5 — Volumetric jitter, and file the shared transform buffer (F8, F7)
- `atmospheric_volumetric.frag`: interleaved-gradient-noise offset per pixel
on the march start (`fract(52.9829189 * fract(dot(gl_FragCoord.xy,
vec2(0.06711056, 0.00583715))))`), plus the existing quarter/half-res
upsample. Automated: a fixed-camera capture's step-banding metric (row
autocorrelation at the step period) drops below the current value;
GPU delta < 0.02 ms.
- File issue: the shadow pass uploads a second transform buffer; a future
GPU-culling step should bind the main pass's instance SSBO. Not built here.
**OUTCOME (2026-08-22):** IGN jitter landed in atmospheric_volumetric.frag
(pack-on only); F7 filed as #421.
## VM6 — Foliage wind (the feature)
### What "real movement" means here
Trees do not wave like flags. Real foliage has **three motions at three
speeds**: the whole tree leans slowly with the mean wind (seconds), branches
swing at their own natural frequency (about one second), and leaves flutter
fast and independently (fractions of a second). Gusts come and go over tens
of seconds. Neighbouring trees are out of step. Trunk bases do not move.
Anything that ignores one of these reads as "screensaver" which is what
the owner does not want.
### The rule for what sways (no guessing, data-driven)
- **Candidate set:** procedural scenery only entity ids in the
`0x8XXYYIII` namespace (`ProceduralSceneryIdAllocator`, top nibble 0x8).
Landblock statics (fences, signposts, buildings), weenies, creatures and
players never sway.
- **Foliage subsets:** within a candidate, the **alpha-cutout material
subsets** (leaves, fronds, bushes, grass tufts). Rocks have none and stay
still; a tree's trunk is opaque and gets only the slow lean (below).
- **Opaque subsets of a candidate that also owns cutout subsets** (trunks,
branches) receive the slow lean only, scaled by height, never the flutter.
- A pack may **exclude** object ids (`FoliageExclusions`, a list in the
descriptor) for the rare scenery object that is cutout but not foliage.
There is no include list: the rule is the rule.
This is a render-only classification flag per batch bits 1 and 2 of
`BatchData.flags` (`mesh_atmospheric.vert` already reads `flags`; bit 0 is
the #226 built-mesh marker). The dispatcher sets it once at classification
time from the entity id namespace and the subset's blend class; the pack-off
`mesh_modern` pipelines never read it.
### The motion (vertex shader, pack variant only)
Applied in `mesh_atmospheric.vert` and in all four
`directional_shadow_world_*` vertex shaders (constraint: **the shadow must
move with the leaf** the caster and receiver displacement are one shared
include, `foliage_wind.glsl`, so they cannot drift apart).
Inputs: instance world origin `o` (translation column of
`Instances[i].transform`), vertex world position `p`, `time` (from
`uCameraAndTime.w`, already in the vertex stage), and the pack's wind block
(new `uFoliageWind` in `atmospheric_common.glsl`):
```
vec4 uFoliageWindDirection; // xy unit direction, z = mean strength [0..1], w = gust strength
vec4 uFoliageWindParams; // x = lean amplitude m, y = branch amplitude m, z = flutter amplitude m, w = max height m
```
All amplitudes are **metres at the canopy top**; they are pack settings with
bounded ranges, not shader constants.
```
h = clamp((p.z - o.z) / maxHeight, 0, 1) // 0 at the base, 1 at the top
k = h * h // bend grows with height²: bases stay put
ph = dot(o.xy, vec2(0.137, 0.291)) // per-tree phase from world position
g = 0.5 + 0.5 * sin(0.05 * t + ph) // gust envelope, ~20 s
+ 0.25 * sin(0.13 * t + 1.7 * ph)
s = mean + gust * g // instantaneous strength
lean = k * leanAmp * s * (0.8 + 0.2 * sin(0.35 * t + ph)) // slow lean
branch = k * branchAmp * s * sin(1.1 * t + ph + 2.0 * h) // ~1 Hz, phase runs up the tree
flutter = h * flutterAmp * s * sin(6.0 * t + 7.0 * fract(sin(dot(p.xy, vec2(12.9898, 78.233))) * 43758.5453))
d = dir * (lean + branch) + perp(dir) * 0.35 * branch + vec2(flutter) * normalize(vec2(cos, sin) of vertex hash)
p.xy += d
p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5) // bend shortens, it does not stretch
```
Opaque (trunk) subsets use `lean` only. Cutout subsets use all three. The
vertex hash decorrelates leaves on the same tree; the instance phase
decorrelates trees. The `p.z` term is the cheap length-preserving correction
so a bent canopy sinks slightly instead of growing.
**Deliberate divergence from the pseudocode above (review fix round A7):**
both `h`'s divisor and the `p.z` correction's divisor guard `maxHeight`
(`amp.w`/`uAtmosphereWindAmplitude.w`) with `max(maxHeight, 0.5)`, i.e.
`h = clamp((p.z - o.z) / max(maxHeight, 0.5), 0, 1)` and
`p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5)`. `wind-canopy-height-metres`
is an author-facing pack setting with no enforced floor; without the guard a
misconfigured near-zero canopy height would divide by a near-zero value and
either blow `h` up to a huge (then clamped) number with a discontinuous
derivative right at the base, or make the bend-shortening term explode.
Flooring at 0.5 m (half the shortest plausible sapling) keeps both terms
well-behaved for any authored value, including 0, and is invisible for every
realistic canopy height (the built-in default is 8 m). Both the shader
(`foliage_wind.glsl`) and the CPU mirror (`FoliageWindModel.Displace`) apply
the guard identically.
Normals are **not** rotated (Gouraud on a cutout leaf with flipped lighting
would flicker); AC's flat-lit foliage does not need it.
### Weather drives it (AC owns the weather)
The pack's `AtmospherePolicyDeclaration` gains a `FoliageWindByWeather` table
keyed by the DAT-classified `AcDream.Core.World.WeatherKind` not the raw
`activeDayGroup` index, which carries no weather meaning by itself.
`WeatherState.cs` already classifies each day group's authored DAT name into
one of these five real kinds, and `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads that classification through the
frame this table reuses it instead of re-guessing from the index:
| WeatherKind | mean | gust |
|---|---|---|
| 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 |
plus a global **Wind** slider (02×) and an **Off** setting. Transitions
between day groups interpolate over the existing weather delta seconds
(`uAtmosphereWeather.z`), so a weather change never snaps. Wind direction is
a pack setting (default NESW, 225°); there is no authored retail wind
direction to read, and the register row says so.
### Indoor, shadows, physics, picking
- Indoors (`!IsOutdoor`) wind is zero EnvCells have no scenery anyway.
- Shadow casters apply the identical displacement (shared include).
- Physics is untouched by construction: the collision BSP is the trunk and
nothing here touches Runtime or Core physics. The acceptance test asserts
the Runtime/physics diff is empty.
- World picking (`WorldPicker`) picks against the undisplaced mesh; a leaf
may be up to `leanAmp + branchAmp` metres from its pick volume. That is
accepted and registered (one row, "render-only foliage displacement");
nobody picks leaves.
### Budget and quality scaling
- Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target:
< 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix
tooling (`tools/run-atmospheric-performance-matrix.ps1`).
- Low preset: lean + branch only (no flutter). Medium/High: all three.
### Acceptance
- Automated: the classification flag is set only for top-nibble-0x8 entities'
cutout subsets (and lean-only for their opaque subsets); pack-off pipelines never
read the flag and VM0's masked comparison is unchanged; the shared include
is byte-identical between receiver and caster variants (test reads both
SPIR-V inputs' source); weather table interpolates without discontinuity;
budget row passes.
- **Owner visual gate**, in this order: (1) Holtburg outskirts, Clear, noon
"barely moving, alive"; (2) same place, Rain or Storm (the WeatherKind
comes from WeatherState's classification of the active day group's DAT
name `/time`-cycle day groups or wait for weather) "clearly windy,
still not a flag"; (3) watch one tree for 30 s gusts arrive and leave, neighbours
out of step; (4) the tree's shadow on the ground moves with it; (5) walk
indoors nothing moves; (6) pack off nothing moves. The owner's words
decide; the numbers above are starting points to tune live.
### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding)
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` (02×, default 1.0), `wind-direction-degrees`
(0360°, 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
`AddDirectionalShadowBatches` (caster) from the identical four inputs
(entity id, `AtmospherePolicyDeclaration.FoliageExclusions` membership,
subset `TranslucencyKind`, and `ObjectRenderData.HasCutoutSubset` computed
once per mesh, not per frame or instance). The shared displacement lives in
`foliage_wind.glsl`, called once each from `mesh_atmospheric.vert` and all
four `directional_shadow_world_*` (opaque/cutout, base/multiview) caster
vertex shaders, reading the ABI v2 `uAtmosphereClockWind`/
`uAtmosphereWindAmplitude` members `AtmosphericPostProcessGraph.ResolveFoliageWind`
resolves once per `frame.Serial` (so the caster, which runs first each
frame, and the receiver read byte-identical values). `FoliageWindModel` is
the CPU mirror pinned by hermetic tests. Register row IA-25. Owed: the
six-step owner visual gate above (implementation is otherwise code-complete
and the automated acceptance criteria pass).
**Review fix round (2026-08-22/23):** an Opus dual-lens review of the three
implementation commits found two blockers, both fixed. (A1) The procedural-
scenery classifier tested bit 31 alone instead of the full top nibble
`0xF000_0000 == 0x8000_0000`, which incorrectly matched `LandblockStatic-
EntityIdAllocator`'s `0xC...` namespace (fences/gates/building shells with a
cutout subset), the `0xDA11_D0xx` paperdoll id, and the `0xFFFF_FF01` portal-
tunnel id as procedural scenery all three would have swayed.
`ProceduralSceneryIdAllocator.IsInNamespace` now does the exact top-nibble
test and `FoliageWindClassification.IsProceduralScenery` delegates to it.
(A2) `GroupKey` (the world-receiver instance-batching key) did not include
`FoliageFlags` while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing the same mesh subset coalesced
into one receiver `InstanceGroup` whose flags were whichever entity
classified it last the "known limitation" paragraph this outcome section
used to carry. `GroupKey` now carries `FoliageFlags`, set exactly once at
group creation from the key, never re-stamped; the two placements now land
in two distinct groups and the receiver agrees with the caster by
construction. Two should-fix items also landed: (A3) the world receiver
pass now explicitly binds `UniformAtmosphericFrame` from the caster's own
per-frame binding (carried on `DirectionalShadowFrameBinding`) instead of
relying on Vulkan not resetting the caster pass's leftover binding; (A4) a
Setup-composed tree's opaque trunk part now gets the trunk flag by OR-ing
`HasCutoutSubset` across all of the entity's currently-resolved sibling
parts (`FoliageWindClassification.ComputeEntityHasCutoutSubset`) instead of
consulting only the trunk part's own (cutout-free) mesh data. Nits A5
(flutter hash relative to instance origin, not absolute world XY fp32
precision at far landblock corners) and A8 (`FoliageWindExclusions` as a
`FrozenSet`) also landed; A7 is the divergence note on the `max(maxHeight,
0.5)` guard above.
**Review fix round 2 (2026-08-23):** a narrow re-review found one more
blocker. (F1) The PRODUCTION packed classifier (`WbDrawDispatcher
.PackedOracle.cs`'s `ClassifyPackedBatches`/`GetOrCreatePackedGroup` the
route `RetailPViewPassExecutor.DrawPackedProductionRoute` actually draws
from) never computed `FoliageFlags` at all: it built its `GroupKey` with the
field defaulting to `0u` and never copied it onto the created
`InstanceGroup`, so production `BatchData.flags` bits 1/2 were always zero
for every scenery entity the world geometry never swayed even though the
independently-classified shadow caster did, so shadows visibly swayed under
rigid trees. Both classifier call sites now compute `FoliageFlags` via the
identical `FoliageWindClassification.Classify` call and entity-scoped
`HasCutoutSubset` OR the classic path uses, and `GetOrCreatePackedGroup`
copies it exactly like `GetOrCreateInstanceGroup` always has. The G2/G3
classified-output digest (`AddOpaqueSubmissionGroup`/
`BuildTransparentSubmissionDigest`) now also folds `GroupKey.FoliageFlags`
into its hash previously present in the key but never actually read by
either digest function, so the fold is correct and symmetric between the
two functions. **Review fix round 3 (N3) correction:** this does NOT mean a
classic-vs-packed divergence is caught today. `CompareClassifiedOutput` (the
method that reads this digest) only runs from
`RenderScenePViewFrameProductController.BuildAndCompare`, which has no
production caller anywhere in `src/AcDream.App/` `FrameRootComposition.cs`
constructs the controller with a real dispatcher, but nothing ever calls
`BuildAndCompare` on it and both of `RenderScenePViewFrameProductTests`'s
own callers construct the controller without the optional `dispatcher`
argument, so `_dispatcher` is `null` and `CompareClassifiedOutput` short-
circuits before reaching the digest at all. The fold is correct and ready
for the day this oracle is wired to a caller; it catches nothing until then.
(F2, medium) The delayed-alpha replay
path (`PrepareDeferredAlphaDraws`) hardcoded `Flags = 1`, dropping bits 1/2
for any group replayed through it a trunk instance promoted into the
alpha-blend group mid-fade (the `#188` translucency-promotion case) would
stop swaying for the duration of its fade; now `1u | key.FoliageFlags`. (F3)
`ComputeEntityHasCutoutSubset`'s three call sites (classic, caster, and the
newly-fixed packed classifier) each allocated a closure over `_meshAdapter`
per Setup entity per frame; a new context-taking overload passes the mesh
adapter as an explicit argument to a `static` lambda instead, letting the
compiler cache one delegate for the method's lifetime. A3's test gap also
closed: `DirectionalShadowGpuTests` now has a companion test proving
`WbDrawDispatcher.BindDirectionalShadowReceiver` (made `internal` for this)
actually emits the `UniformAtmosphericFrame` bind with the exact
buffer/offset/size a `DirectionalShadowFrameBinding` carries, pairing with
the existing test that proves that binding carries the caster's real bind
forward untouched. F4 (the `foliage_wind.glsl` header comment) and F5 (a
comment at the receiver bind site: the caster's own `AtmosphericFrameBuffer-
Binding` has its seven ABI v1 members zero/Identity by construction only
the two v2 wind members are valid safe today because `mesh_atmospheric
.vert` reads this binding solely for wind displacement, a footgun for a
future v1-reading addition to that shader) also landed. F6, noted rather
than fixed, applies to BOTH of the classifier's caches: the classic route's
`EntityClassificationCache`'s `EntityCacheEntry`, and the packed
production route's `PackedProjectionClassificationEntry`/
`PackedClassifiedBatch.Key` (`PackedProjectionClassificationCache`). Both
bake `GroupKey.FoliageFlags` (hence exclusion membership) in at
classification time and neither is proactively invalidated when
`FoliageWindExclusions` changes a newly-excluded or newly-included object
can show its previous classification until its cache entry is next evicted
rather than immediately on a pack switch. The two caches differ in HOW they
eventually recover: `EntityCacheEntry` self-heals per entity, on that
entity's own next eviction (e.g. a landblock demote/reload);
`PackedProjectionClassificationCache.BeginFrame`
(`PackedProjectionClassificationCache.cs:133-137`) instead clears its
ENTIRE cache in one shot whenever `RenderSceneGeneration` changes. Neither
mechanism is keyed to a pack switch specifically, so both are staleness
windows of unknown-but-bounded length, not an immediate reclassification.
Harmless with the pack off (exclusions are pack-scoped); not worth a
proactive invalidation sweep for a rarely-changing, pack-scoped list.
**Review nit A6 (reviewer-filed, landed round 3):** `ResolveFoliageWind`'s
`_windMean`/`_windGust` started at 0 and ALWAYS eased toward the weather
target by clock delta, with no distinction for a graph's first-ever
advance. Two consequences: a pinned clock (`ACDREAM_SKY_PHASE_SECONDS`,
the offline pixel gate's determinism pin) has delta 0 on every advance
after the first, so the wind reached only whatever fraction the first
(clamped-to-1-second) step produced and sat there forever every offline
capture under-represented the motion; live, the first 10 s after a graph
is constructed (pack selection / login) spun up from dead calm even though
the weather already IS what it is, because there was no previous frame to
ease from. Fixed at the root: the first advance
(`_windFrameSerial == -1`, the constructor sentinel) now SNAPS `_windMean`/
`_windGust` straight to the target; every later advance eases over
`WeatherSystem.TransitionSeconds` exactly as before. A new
`SetWindClockSecondsOverrideForTesting` test-only seam
(`AtmosphericPostProcessGraph`, `_windClockSecondsOverride` no longer
`readonly`) lets a hermetic test advance the pinned clock by an exact,
deterministic amount between two resolves proving both the first-advance
snap and that a SECOND advance still eases normally without a real-time
`Thread.Sleep`.
**Review fix round 4 (2026-08-23): wind decoupled from the shadow gate.**
The reviewer's offline pixel apparatus caught a real design defect the
first three rounds' CPU-side reasoning could not see: foliage wind was
welded to "directional shadows rendered this frame." Evidence: at the
High preset with `sun-shadow-strength=0` and wind-strength 2 + 1 m
lean/branch amplitude, wind-on vs wind-off at the same pinned clock
differed by only 4965 px inside the apparatus's own 22 px run-to-run
noise floor, i.e. no measurable motion at all. A CPU probe independently
confirmed `ResolveFoliageWind` itself was correct (first advance snaps
exactly to Clear's 0.25/0.15, the gate is 1, one graph instance) the
correct uniform was computed but never reached the world pass. Root
cause: `DirectionalSunShadowRenderer.Render` left `_currentFrameBinding`
at its pure `Disabled` (no-buffer) default on both early-out paths
(`!environment.ShouldRender`, `ResidentWindowUnavailable`);
`WbDrawDispatcher.PipelinesFor`/`TerrainModernRenderer`'s matching
selection logic only chose the atmospheric receiver pipeline
(`mesh_atmospheric`, which alone `#include`s `foliage_wind.glsl`) when
`TryGetCurrentFrameBinding` returned true; with no buffer it always
returned false, so the world pass silently ran the plain `mesh_modern`
pipeline instead which has no wind code at all. Because the shadow
gate is `ActiveDayGroupMultiplier = dayGroupPolicy × elevationResponse ×
strength`, this killed wind every NIGHT (elevation response 0), at
user `sun-shadow-strength` 0, and under the portal/login cover not
just in the artificial `strength=0` repro.
Fix (decouple, don't patch): `DirectionalShadowFrameBinding` gained
`IsBindableFor` ("a real current-frame allocation exists") separate from
`IsValidFor` ("...and it is Enabled with real shadow content" the
volumetric pass still gates on this, unchanged);
`TryGetCurrentFrameBinding` now returns `IsBindableFor`. When the
built-in pack supplies an `AtmosphericFrame` binding (declared packs
never do, so they are unaffected), `Render`'s two early-out paths call a
new `PublishDisabledReceiverBinding`: it allocates one real ring slice
and writes a DISABLED `DirectionalShadowUniforms` block every matrix
Identity, every control/bias term zero, `TextureAndFlags` all zero (bit
0 clear is exactly what `directional_shadow_receiver.glsl`'s
`acdreamDirectionalShadowVisibility` already reads as "no shadow, full
visibility" via its existing early `return 1.0`), and a UNIT light
direction `(0,0,1)` so a fragment shader's `normalize()` can never
produce NaN. `BindDirectionalShadowReceiver` and
`TerrainModernRenderer`'s shadow-buffer bind now check `Buffer is not
null` instead of `Enabled`, so this disabled block actually gets bound
once it is selected. Consequence in one sentence: the built-in pack's
world pass now always runs the receiver shader; with shadows gated off,
the shadow block's own flag bit makes its shadow term numerically the
plain lighting sum, while wind keeps moving correctly.
## VM7 — Closeout and merge
Closeout and merge
- Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR
reference matrix re-run for the changed presets, VM0's masked comparison
on the final binary, the connected lifecycle/reconnect route.
- Register: rows for the wind direction constant, the render-only
displacement vs picking, and whatever VM2 decides; TS-52 stays retired.
- Roadmap: move Campaign VM to shipped; AR's "final owner gate" rows that
VM0/VM3/VM6 satisfy are marked so in the AR plan.
- Memory: a `project_visualmaster_campaign.md` digest entry (the wind rule,
the linear-light decision, the VM0 oracle recipe).
- Owner: final pack-off / pack-on visual matrix, then merge to main.
## What this campaign does NOT do
- It does not touch the retail-faithful default path except through VM0's
evidence and VM1's removal of an un-anchored constant.
- It does not add normal maps, PBR, SSAO, water, or a second weather system.
- It does not move trunks' collision, walkability, or anything in Runtime.
- It does not animate landblock statics, weenies, grass decals that are not
scenery, or creatures.
- It does not rebuild the shadow transform buffer (filed, VM5).
- It does not re-port #226 to the single-pass path unless VM2 proves retail
ran it.
## Proposed owner goal
> **Campaign VM — VisualMaster.** Drive
> `docs/plans/2026-08-22-visualmaster-campaign.md` to shipped on branch
> `claude/git-sync-status-5fb1d2`: VM0 proves the pack-off path is
> pixel-identical to `6c79d35c` outside the terrain/detail masks with no
> production perf or allocation regression; VM1 removes the un-anchored
> detail fade; VM3 puts the post stack in linear light with a numerically
> neutral preset; VM4 makes every document truthful; VM5 de-bands the
> volumetrics; VM6 adds weather-driven foliage wind (scenery-only, cutout
> subsets, three motions, shadows follow) behind the render pack. Fable plans
> and verifies, Sonnet implements, Opus dual-lens reviews every slice. Stop
> me only for: the retail cdb read (VM2), the three visual gates (VM3, VM6,
> VM7), and the merge. Every closeout claim must name its command, lane and
> artifact.