Adds the independent Campaign AR review (docs/research/2026-08-22-campaign-
ar-review.md): architecture, technical correctness, performance, coverage.
Findings F1-F8; F1 (default-path invariance proven only on a 2x2 recording-
device oracle) blocks merge; F2 (10 m/50 m detail fade has no retail anchor);
F3 (the port is retail's two-pass fallback; single-pass stage path
undetermined); F4 (post stack runs in gamma space); F5 (four overclaiming
sentences, one premise error the reviewer owns).
Campaign VM plans the closure: VM0 masked pixel + production perf A/B vs
6c79d35c; VM1 removes the fade (retail attenuates by mips); VM2 one cdb read
of m_caps.bCanDoSinglePassDetailing; VM3 linear-light post stack with a
numerically neutral preset; VM4 truthful docs; VM5 volumetric jitter; VM6
weather-driven foliage wind - procedural-scenery (bit-31 id namespace) cutout
subsets only, three motions (lean/branch/flutter) with gusts and per-tree
phase, shadows share the displacement include; VM7 closeout + merge.
Roadmap gains the Campaign VM pointer beside Campaign AR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
19 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.
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 | owner action (launch retail) |
| 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 VM0 → VM1 → VM4 → VM5 → VM3 → VM6 → VM2 (whenever the owner has retail up) → 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.
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. 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 outputsdetail.rgbaunscaled. - 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.
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.
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; 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
0x8XXYYIIInamespace (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 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 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.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.