acdream/docs/plans/2026-08-22-visualmaster-campaign.md
Erik 4dbbe8fead docs(plans): Campaign VM — VisualMaster plan + the Campaign AR review it closes
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>
2026-08-22 18:17:08 +02:00

19 KiB
Raw Blame History

Campaign VM — VisualMaster

Date: 2026-08-22 · Status: PLANNED — awaiting the owner's goal Phase id: Campaign VM — named by the project owner on 2026-08-22 Branch: claude/git-sync-status-5fb1d2 (= main + Campaign AR, c51b07ef) Predecessor: Campaign AR and its independent review Scheduling: owner-directed, like Campaign AR. Does not displace M4.

Goal

Make Campaign AR provably safe to merge and then finish the look: close every review finding with evidence rather than assertion, put the Tier-1 post stack in linear light so its controls mean what they say, and add the one cheap, high-impact enhancement still missing from the discussed scope — gentle, believable wind in Dereth's foliage, driven by AC's own authored weather, opt-in through the same render-pack contract, with the default retail-faithful path untouched.

Campaign VM is shipped when both sides of the final matrix pass:

  • pack off: pixel-identical to the pre-campaign build outside the two intended parity changes (terrain normals, building detail), with production CPU/GPU/allocation within the pre-campaign noise envelope; and
  • pack on: linear-light post stack, registered-or-removed detail fade, moving foliage that the owner judges "real, not a screensaver", shadows that move with the leaves, and all of it inside Campaign AR's declared budgets.

Operating model

The launcher/AR cadence, unchanged:

  • Fable plans, verifies claims against source/decomp, writes the closeouts, and drives the work order. Nothing in a closeout may claim more than its artifact shows — every "passes" names the command and the lane.
  • Sonnet implements each slice against the pinned contract below.
  • Opus reviews every slice with two lenses (architectural / retail- faithful) before its closeout; findings go through a fix round and a narrow re-review.
  • The owner is stopped for exactly three things: the cdb read on the retail client (VM2), the visual gates (VM3, VM6, VM7), and the merge.

Rules binding on every slice: no workarounds (CLAUDE.md), no guessed AC-specific constants — a number without a decomp/DAT anchor gets a register row in the same commit — and the pack-off path is the oracle: any slice that moves a pack-off pixel outside its declared mask is a bug, not a tuning.

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

  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. 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_devicem_caps.bCanDoSinglePassDetailing, m_caps.bTexOpDotProduct3, and the raw m_D3DCaps.TextureOpCaps; qd. Record the answer in the #226 note and the review doc.

  • bCanDoSinglePassDetailing == 0 → the port is the path players saw; close.
  • == 1 → file the single-pass stage math as the correct target, register the current blend as a bounded divergence, and schedule the re-port as its own slice (it is a fragment-shader change only).

Acceptance: the value is recorded with the binary GUID and the date.

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 (02×) and an Off setting. Transitions between day groups interpolate over the existing weather delta seconds (uAtmosphereWeather.z), so a weather change never snaps. Wind direction is a pack setting (default NE→SW, 225°); there is no authored retail wind direction to read, and the register row says so.

Indoor, shadows, physics, picking

  • Indoors (!IsOutdoor) wind is zero — EnvCells have no scenery anyway.
  • Shadow casters apply the identical displacement (shared include).
  • Physics is untouched by construction: the collision BSP is the trunk and nothing here touches Runtime or Core physics. The acceptance test asserts the Runtime/physics diff is empty.
  • World picking (WorldPicker) picks against the undisplaced mesh; a leaf may be up to leanAmp + branchAmp metres from its pick volume. That is accepted and registered (one row, "render-only foliage displacement"); nobody picks leaves.

Budget and quality scaling

  • Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target: < 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix tooling (tools/run-atmospheric-performance-matrix.ps1).
  • Low preset: lean + branch only (no flutter). Medium/High: all three.

Acceptance

  • Automated: the classification flag is set only for 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.