Binding rule: 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 cdb-read on the PDB-paired client and cited with the binary GUID. Records that the owner's live 'too bright' was the pack-on gamma-space tonemap (VM3), not the detail overlay (+3% fallback / -10% real path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 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 | 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 |
| 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
- 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. 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 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.
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; 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.