acdream/docs/plans/2026-08-22-visualmaster-campaign.md
Erik 26adf75079 docs(render): VM6 code-complete — foliage-wind pixel proof, repeat-floor apparatus, ledger (Campaign VM)
Closes the automated half of slice VM6 at 754d59d9 and records how it was
proven. The first pixel method (clock pin 0 s vs 3 s, wind-off pair as the
control) is recorded as confounded: the treeline silhouette carries a
bimodal 0-or-~280 px rasterisation churn between runs of the same binary,
and two conclusions drawn from it were retracted. The replacement
apparatus (same pin, wind on vs off, two captures per arm, robust mask =
AND of the four cross pairs minus both repeat pairs, shadows off so only
geometry can move) found the round-4 defect (wind welded to the shadow
gate: 1 m wind = 49-65 px vs a 22 px floor while the CPU state was
correct) and, after round 5, gives 14,993 robust wind pixels vs a 65 px
floor, every one on a treeline tree, hillside tree or shoreline bush, with
the wind-off frame matching the pre-round-4 plain pipeline at mean |d|
0.007 (round 4's eec95535: 1.77, the overhead-sun regression the narrow
review caught).

tools/vm6/wind-pixel-proof.py is the analysis command the closeout names;
evidence overlays under docs/research/evidence/vm6. Plan: VM6 ledger row
CODE-COMPLETE 2026-08-23, owner visual gate owed (section VM6 Acceptance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:01:18 +02:00

47 KiB
Raw Blame History

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 and its independent review 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
VM1 F2/F3 — #226 single-pass re-port + fade removal CLOSED 2026-08-2205970306, 388457a7, ae651312, closeout; Opus APPROVE
VM2 F3 — which retail detail path ran CLOSED 2026-08-22 — single-pass; see cdb note
VM3 F4 — linear-light post stack CODE-COMPLETE 2026-08-2287677f9c, 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 CODE-COMPLETE 2026-08-230930c35d, 39e8408c, 43e3abed, a82959f1, fccba839, eec95535, 754d59d9; five Opus review rounds (round 3 APPROVE; round 4 REJECT caught the overhead-sun regression, fixed in round 5); pixel proof note (14,993 robust wind px vs 65 floor, scenery only; wind-off frames match the plain pipeline at mean
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 (code-complete, gate owed) → 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_devicem_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.

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 NE→SW, 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; code-complete 2026-08-23 at 754d59d9; 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 #includes 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.

Review fix round 5 (2026-08-23) correction to the paragraph above: the claim "the flag bit makes it numerically the plain lighting sum" was false as written — F1 BLOCKER, found by the reviewer's own offline pixel apparatus reading the shader source, not by a repro capture (the disabled block's (0,0,1) direction differs too little from straight-overhead to show up above the apparatus's noise floor at these amplitudes, so the mislighting was invisible to that specific test even though it is real). Both receiver VERTEX shaders (mesh_atmospheric.vert, terrain_atmospheric.vert) sourced the sun direction used to compute directionalLit/vDirectionalLit from the shadow block's own uShadowLightDirectionAndSource, unconditionally — not only the visibility term, which was already correctly flag-gated. Every shadow- gated-off frame was lighting outdoor terrain and objects from the disabled block's (0,0,1) placeholder — straight overhead — regardless of the authored sun's actual position. The celestial shadow source direction is not the authored light direction either, so publishing it instead would not have restored parity. Fixed in the shaders themselves: both receiver verts now branch on the SAME flag bit ((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT plain-pipeline expression (-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert, matching mesh_modern.vert; -uLights[0].dirAndRange.xyz in terrain_atmospheric.vert, matching terrain_modern.vert) instead of the shadow block's direction. Corrected statement: with the flag bit clear the receiver shaders take visibility 1.0 AND fall back to the authored uLights direction (round 5), so a gated-off frame is numerically the plain pipeline. Wind is unaffected — foliage_wind.glsl reads only the AtmosphericFrame half of set 3, never the shadow block.

Pixel proof after rounds 45 (2026-08-23, eec95535754d59d9): the same apparatus that found the defect — High preset, sun-shadow-strength=0 so no shadow term can move, same pinned clock, wind on vs off, two captures per arm so the repeat pairs measure the run-to-run floor — gives a robust wind mask of 13,854 px on eec95535 and 14,993 px on 754d59d9 (amplified: strength 2, lean/branch 1 m, flutter 0.5 m) against a 1965 px floor, and 1,402 px at the default Clear strength; the wind-off frame on 754d59d9 matches the pre-round-4 plain pipeline at mean |Δ| 0.007 (round 4's eec95535 was 1.77 — the overhead-sun regression); every marked pixel is a treeline tree, hillside tree or shoreline bush, and no house, fence, road, lifestone, ground, water or UI pixel moves. The first method tried (clock pin 0 s vs 3 s, wind-off pair as the control) is recorded as confounded in the note and must not be reused: the treeline silhouette carries a bimodal 0-or-~280 px rasterisation churn between runs that lands on either side of the subtraction by luck. Tool: tools/vm6/wind-pixel-proof.py; note: docs/research/2026-08-23-vm6-foliage-wind-pixel-proof.md.

VM7 — 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.