Opus dual-lens review of the three VM6 commits (0930c35d,39e8408c,6cc5e183) found two blockers and two should-fix issues; all landed here along with the review's nits and documentation corrections. Blockers: - A1: the procedural-scenery classifier tested bit 31 alone instead of the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched LandblockStaticEntityIdAllocator'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; FoliageWindClassification delegates to it. - A2: GroupKey (the receiver's instance-batching key) did not carry FoliageFlags while the caster's dedup key already did, so a scenery instance and a non-scenery instance sharing a mesh subset coalesced into one receiver InstanceGroup whose flags were last-writer-wins — disagreeing with the correctly-keyed caster. GroupKey now carries FoliageFlags, computed before key construction and set exactly once at group creation; the imperative re-stamp is gone, and CachedBatch's now-redundant FoliageFlags field is removed. Should-fix: - A3: the world receiver pass bound UniformAtmosphericFrame only by accident (leftover from the caster pass, which runs first each frame, since Vulkan binding state isn't reset between passes). DirectionalShadowFrameBinding now carries the caster's exact AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds it explicitly. - A4: a Setup-composed tree's opaque trunk part never got the trunk flag because HasCutoutSubset is cached per GfxObj part, not per entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now ORs HasCutoutSubset across an entity's resolved sibling parts once per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches via a new optional override parameter. Nits: A5 hashes the per-vertex flutter seed relative to the instance origin instead of absolute world XY (fp32 sin() precision loss at far landblock corners), mirrored in both foliage_wind.glsl and FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as a deliberate pseudocode divergence; A8 switches FoliageWindExclusions' construction to ToFrozenSet() and softens the "never stale" doc comment to "no slower than one frame behind." Tests added: top-nibble classification (0xFFFFFFFFu now correctly false), GroupKey inequality across entity-driven scenery/landblock- static classification, a caster-batch test proving the same pairing never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end two-part-Setup tests, the caster→receiver AtmosphericFrame binding carry-through, flutter-hash translation invariance relative to instance origin, and a Storm-wind mid-height displacement floor guarding against a "no motion" regression. Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits 1 and 2", "all four" caster shaders, and top-nibble wording throughout; the owner gate checklist's Rain/Storm step; the stale v1-only shader- interface compatibility entry; semantic-bindings-v1.md's v2 members folded into the main 192-byte block; the IA-25 register row's top- nibble wording; AtmosphericFrameInputs.cs's ABI size reference. foliage_wind.glsl's A5 change recompiled exactly the five shaders that include it (mesh_atmospheric.vert, the four directional_shadow_world_* casters) plus the manifest; no other .spv changed. Verify: Release build 0 warnings/0 errors. App hermetic-lane filter 6,041/0 failed (no environment-specific failures this run). RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0 failed across 15 projects. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
35 KiB
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-22 — 05970306, 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-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
- Build
6c79d35cin a throwaway worktree (Release). Capture withtools/run-offline-pixel-gate.ps1 -Out artifacts/vm0/base— the tool's own documented baseline mode. - Capture HEAD pack-off twice with the same tool and camera set:
BuildingDetailTextures=false(isolates the A2 normal change) and=true(the shipped default). - Extend the tool's compare step with a terrain mask (same mechanism as
the existing
sky-mask.png): pixels whose depth/material belong toterrain_modernare excluded from the identity assertion and reported separately. - 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).
- 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. - 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
vDetailFadefrommesh_detail.vert/.frag(mesh_detail.vert:83); the fragment outputsapplyFog(detail.rgb)with alphadetail.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
RetailDetailTextureContractmembers 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.
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 inatmospheric_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, thenacdreamEncode(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)andpow(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). SeeAtmosphericColorPipelineTestsfor 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.
acesFittedapplied directly to the gamma value versusencode(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.05–0.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):
- Noon outdoors, walk a shaded street: do shadows read as "deep" or as "crushed"? If crushed → raise Exposure to 0.9–1.0 (mid-greys will brighten ~10 %) or lower Filmic strength to 0.7–0.8 (lifts the toe, keeps the shoulder).
- 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).
- Dusk (
/timeor 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. - 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).
- 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
0x8XXYYIIInamespace (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 (0–2×) 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 toleanAmp + branchAmpmetres 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 (0–2×, default 1.0), wind-direction-degrees
(0–360°, default 225 — no authored retail wind direction exists to read),
wind-lean-metres (default 0.25), wind-branch-metres (default 0.15),
wind-flutter-metres (default 0.05, forced to 0 on the Low preset),
wind-canopy-height-metres (default 8). The mean/gust rows live in
AtmospherePolicyDeclaration.FoliageWindByWeather, keyed by NAME
(FoliageWindWeatherPoint.WeatherKind, an exact ordinal match against
AcDream.Core.World.WeatherKind's member names — Clear 0.25/0.15,
Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm 1.00/0.75),
not the raw activeDayGroup index the original design used: the index
carries no weather meaning by itself, and WeatherState.cs already
classifies each day group's authored DAT name into one of these five real
kinds — the same fact AtmosphericFrameInputs.Weather /
uAtmosphereWeather.x already threads through the frame, reused here
instead of re-guessed. RenderPackAtmospherePolicyEvaluation.FoliageWind
matches by weather.ToString() and falls back to the declared Clear row for
an unlisted kind. Classification bits live in
FoliageWindClassification (AcDream.App.Rendering.Wb): bit 1 (0x2)
cutout foliage, bit 2 (0x4) trunk, computed once per (entity, subset) in
WbDrawDispatcher.ClassifyBatches (world receiver) and
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.
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.mddigest 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.mdto shipped on branchclaude/git-sync-status-5fb1d2: VM0 proves the pack-off path is pixel-identical to6c79d35coutside 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.