# 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 | automated + Fable report | | VM1 | F2 — invented detail fade | automated | | 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 | automated + **owner visual** | | VM4 | F5 — overclaiming docs, incl. the reviewer's own | docs | | VM5 | F8 — volumetric banding; F7 filed | automated | | VM6 | Foliage wind | automated + **owner visual** | | VM7 | Closeout: full gates, register, roadmap, merge | **owner** | Order is VM2 (done) → VM0 → VM1 → VM4 → VM5 → VM3 → 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. **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 `detail.rgba` unscaled. - 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. **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; expect exposure to return to ~1.0 and the bloom threshold to move up. **Acceptance:** neutral preset ≡ pack-off within tolerance (new automated test on the recording RHI plus VM0's masked tool on a real capture); Stage-1 luminance table re-captured; owner visual gate: "same look as accepted, no clipping, highlights roll off". Budget unchanged (two `pow` per pixel). ## VM4 — Truthful documents (F5) 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. ## 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`, bit 31). 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 — bit 1 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 six `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. 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 `FoliageWind` table keyed by the same categorical `activeDayGroup` the rays already use: | Day group | mean | gust | |---|---|---| | Clear | 0.25 | 0.15 | | Cloudy | 0.45 | 0.30 | | Overcast | 0.60 | 0.35 | | Rainy | 0.85 | 0.60 | 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 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 bit-31 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, Rainy — "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. ## 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.