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>
This commit is contained in:
parent
c51b07ef95
commit
4dbbe8fead
3 changed files with 638 additions and 0 deletions
|
|
@ -2072,6 +2072,8 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
|
|||
|
||||
**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. The [Stage 2 connected and closeout gate](../research/2026-08-22-atmospheric-stage2-connected-gate.md) now closes every available machine-local ACE, dense A/B, long-lifetime, graphical-package, shader, Release, and complete-test row. The implemented two-client gate proved that ACE rejects two concurrent characters on the one available account and is ready for separate observer credentials without recording secrets. A distinct-account remote-player row, unavailable physical GPU classes, and final project-owner pack-off/pack-on acceptance remain external gates; Campaign AR is not yet declared shipped.
|
||||
|
||||
**Planned, owner-directed — Campaign VM (VisualMaster):** the [VisualMaster campaign](2026-08-22-visualmaster-campaign.md) closes the [Campaign AR review](../research/2026-08-22-campaign-ar-review.md) findings with evidence (pack-off pixel/perf invariance vs `6c79d35c`, the un-anchored detail fade, a linear-light post stack, truthful closeouts, volumetric jitter) and adds weather-driven foliage wind behind the render pack. Named by the owner 2026-08-22; awaiting the owner's goal.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting work tracked in parallel
|
||||
|
|
|
|||
382
docs/plans/2026-08-22-visualmaster-campaign.md
Normal file
382
docs/plans/2026-08-22-visualmaster-campaign.md
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# 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.
|
||||
|
||||
## 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_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 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.
|
||||
254
docs/research/2026-08-22-campaign-ar-review.md
Normal file
254
docs/research/2026-08-22-campaign-ar-review.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# Campaign AR — independent deep review
|
||||
|
||||
**Date:** 2026-08-22 · **Reviewer:** Claude (Fable 5), report-only · **Scope:**
|
||||
`main (6c79d35c) .. c51b07ef` — 378 files, +52,639 / −954 — codex's atmospheric
|
||||
rendering + terrain fidelity campaign, plus the imported signal-sequencing
|
||||
commit. Axes: architecture, technical correctness, performance, coverage of
|
||||
the discussed scope.
|
||||
|
||||
Every claim below was checked against the source, the decomp, or a test run
|
||||
made during this review. Where codex's docs and the evidence disagree, the
|
||||
evidence is cited.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
A substantial, technically competent body of work that delivers almost
|
||||
everything we discussed. The shadow implementation in particular is the
|
||||
textbook recipe done properly. The architecture is sound and honours the
|
||||
project's boundaries (Runtime/physics diff is empty, Core changes are additive
|
||||
metadata, the built-in pack uses the same registry as external packs).
|
||||
|
||||
The weaknesses are mostly in **evidence and provenance**, not code:
|
||||
|
||||
1. default-path invariance is asserted on a toy oracle, never measured
|
||||
against the pre-campaign build;
|
||||
2. the #226 detail-fade constants are invented and presented as retail;
|
||||
3. the Tier-1 post stack runs in gamma space;
|
||||
4. several closeout sentences claim more than their artifacts prove.
|
||||
|
||||
**Recommendation:** do not merge to main until the two F1 checks below have
|
||||
run; decide F2; schedule F3/F4 as follow-ups; then the owner visual gate.
|
||||
|
||||
---
|
||||
|
||||
## Coverage against the discussed scope
|
||||
|
||||
| Discussed | Status | Notes |
|
||||
|---|---|---|
|
||||
| Dynamic shadows from trees, monsters, houses | **Delivered** | Camera-relative stable CSM, 2–4 cascades, opaque + alpha-cutout casters, animated casters, terrain + world receivers. Extended to moon shadows (doc records owner approval 2026-08-22). |
|
||||
| Bloom, ACES tonemap, colour grade, vignette | **Delivered** | Gamma-space caveat (F4). |
|
||||
| Screen-space sun rays | **Delivered** | Sky-depth occlusion mask, 48-tap radial; sun-only. |
|
||||
| Volumetric shafts | **Delivered** | Reuses shadow depth; sun-only; no jitter (F8). |
|
||||
| Rays composited before tonemap | **Delivered** | |
|
||||
| Ray/shaft intensity from authored weather | **Delivered** | `AtmospherePolicyDeclaration` maps `activeDayGroup` + sun elevation in the pack descriptor. |
|
||||
| Opt-in, retail default authoritative, via plugin API | **Delivered** | BCL-only `AcDream.Plugin.Abstractions.Rendering`, SDK validator, three samples. |
|
||||
| #226 detail overlay | **Delivered, with caveats** | Buildings + EnvCells (correct per decomp), through the previously dead checkbox. See F2/F3. |
|
||||
| Terrain vertex normals (A2) | **Delivered, verified** | Retail `calc_lighting` 0x00531700 port. See F5d for the premise correction. |
|
||||
| Subdivision (A3) | Rejected | Agree. |
|
||||
| Tree wind / sway | **Not present** | Raised after codex's brief; not a gap in their work. Still the cheapest item on the list. |
|
||||
|
||||
---
|
||||
|
||||
## Findings, by severity
|
||||
|
||||
### F1 — HIGH (evidence): default-path invariance is unproven
|
||||
|
||||
The plan says `NoOpRenderPackProductionIntegrationTests` "pins the
|
||||
pre-campaign pass list, pipeline set, draw/dispatch tuple, framebuffer
|
||||
SHA-256". It does — for a **2×2 synthetic framebuffer, one draw call, one
|
||||
pipeline, on a `RecordingGpuDevice` that does not rasterize**
|
||||
(`tests/AcDream.App.Tests/Rendering/Packs/NoOpRenderPackProductionIntegrationTests.cs`).
|
||||
It proves the controller arm adds nothing to a toy composition. It does not
|
||||
prove the real default frame is unchanged.
|
||||
|
||||
Meanwhile the hot default draw path was **refactored, not just extended**:
|
||||
`WbDrawDispatcher.Rhi.cs` +512/−44 replaces the `MeshPipelineSet`,
|
||||
`BindRingSection`/`WriteRingSection` instance and indirect-command handling
|
||||
with variant-aware equivalents. `WbDrawDispatcher.cs` +120, `WorldSceneRenderer`
|
||||
+127, `TerrainAtlas` +187. And the default path legitimately changed in two
|
||||
places (A2 normals; the detail overlay, which is **on by default**).
|
||||
|
||||
`tools/run-offline-pixel-gate.ps1` has a `-Baseline` mode designed for exactly
|
||||
this ("capture a baseline at the parent commit, then gate the slice"). No
|
||||
record shows it was run against a `6c79d35c` capture. The six physical
|
||||
"retail rows" record zero pack *work* and performance, not pixel identity.
|
||||
|
||||
**Ask:**
|
||||
1. Capture `6c79d35c` pack-off with the pixel gate; capture HEAD pack-off with
|
||||
`BuildingDetailTextures=false`; diff. Expected: differences confined to
|
||||
terrain shading (A2). Anything outside a terrain mask is a regression.
|
||||
2. A clean uncapped Release production A/B (no automation observer, no
|
||||
validation layers) at the CLAUDE.md profile camera: CPU/GPU p50/p99 and
|
||||
`alloc_kb` pre vs post. The connected dense-town numbers cannot serve (see
|
||||
F5b).
|
||||
|
||||
### F2 — HIGH (process): the #226 fade constants are invented and unregistered
|
||||
|
||||
`RetailDetailTextureContract.FullDetailDistanceMetres = 10f` /
|
||||
`ZeroDetailDistanceMetres = 50f` drive `mesh_detail.vert:83`
|
||||
(`clamp((50 − z) / 40, 0, 1)`). The pseudocode note presents "full through
|
||||
10 m; linear 10–50 m" as part of the retail contract. **Nothing in retail has
|
||||
a distance gate on detail:** `RenderDeviceD3D::DrawBuilding` (0x0059f2a0),
|
||||
`DrawEnvCell` (0x0059f170), `D3DPolyRender::RenderMeshSubset` (0x0059ca10) and
|
||||
`ACRender::SetDetailSurfaceInternal` (0x006b6280) install surface, tiling,
|
||||
WRAP, LINEAR×3 and the blend — no viewer-distance term. Retail's distance
|
||||
attenuation is mip averaging (LINEAR mip filter), which for the live category
|
||||
texture (mean factor 1.033) converges to near-neutral on its own.
|
||||
|
||||
This is a guessed AC-specific constant in a class named `Retail…Contract`,
|
||||
with TS-52 retired and no replacement register row — a double breach of the
|
||||
workflow rules. **Ask:** either drop the ramp and rely on mips (retail), or
|
||||
keep it and file the register row naming it an acdream adaptation.
|
||||
|
||||
### F3 — MEDIUM (premise): #226 ports retail's two-pass fallback; which path real hardware ran is undetermined
|
||||
|
||||
`SetDetailSurfaceInternal` sets the `DSTCOLOR + INVSRCALPHA` framebuffer blend
|
||||
**only when `stage == 0`** (the two-pass fallback). With
|
||||
`m_caps.bCanDoSinglePassDetailing`, `RenderMeshSubset` and `landPolyDraw`
|
||||
call it with stage 1 and the combine is the texture-stage setup in
|
||||
`D3DPolyRender::SetSurface` (0x0059c4d0): stage 0 alpha `PREMODULATE`, stage 1
|
||||
colour `BLENDCURRENTALPHA(texture, current)` — a **lerp toward the detail
|
||||
colour by alpha**, not a multiply. That capability requires
|
||||
`D3DTEXOPCAPS_BLENDCURRENTALPHA` and `D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6).
|
||||
PREMODULATE was rarely advertised by consumer drivers, so the fallback is
|
||||
*probably* what players saw — but that is a recollection of driver caps, not
|
||||
evidence. One cdb read settles it:
|
||||
`dt acclient!RenderDevice::render_device->m_caps` on the PDB-paired binary.
|
||||
Note this caveat also applies to the reviewer's own earlier "retail's detail
|
||||
pass brightens" finding, which was derived from the same fallback path.
|
||||
|
||||
### F4 — MEDIUM (technical): the Tier-1 post stack runs in gamma space
|
||||
|
||||
The main world renders retail's fixed-function, gamma-encoded colours into
|
||||
`Rgba16Float`. Nothing decodes them: bloom thresholds Rec.709 luma of
|
||||
gamma values, `acesFitted` (Narkowicz) is applied to gamma values, saturation
|
||||
and the 0.5 contrast pivot assume linear, and the result is written to the
|
||||
UNORM swapchain without re-encoding (`atmospheric_filmic.frag`,
|
||||
`atmospheric_bloom_downsample.frag`; no `pow`/sRGB anywhere in the
|
||||
atmospheric shaders). The default `exposure = 0.80` is the compensation — ACES
|
||||
maps gamma-0.5 to 0.62 at exposure 1.0, which is the "too bright" the owner
|
||||
corrected live.
|
||||
|
||||
It is an opt-in look and the owner accepted it, so this is not a blocker.
|
||||
But the fix is cheap (decode `pow(c, 2.2)` where the world colour is read,
|
||||
encode at the end) and would make threshold/exposure/contrast behave like
|
||||
every other tool's controls. Schedule as a slice.
|
||||
|
||||
### F5 — MEDIUM (docs): closeout claims exceed their evidence
|
||||
|
||||
a. "Pins the pre-campaign … framebuffer SHA-256" — see F1.
|
||||
b. "The user's turning hitch reproduces on the retail path" is drawn from
|
||||
`connected-dense-town-20260822-133727` (retail/off: CPU p50 **27.8 ms**,
|
||||
`alloc_kb p50 = 9,830`, 31 Gen0 GCs in 216 frames). The Slice H closeout
|
||||
already documents that the connected automation observer "intentionally
|
||||
allocates in proportion to scene size; 3.9–10.5 MiB/frame outdoor readings
|
||||
are automation observer cost". Pre-campaign production dense Caul measured
|
||||
3.0/4.9 ms CPU p50/p95. The A/B is valid for the *Medium-minus-retail
|
||||
increment* (~0.7–0.9 ms GPU); it says nothing about the owner's hitch or
|
||||
about production default-path cost.
|
||||
c. "14,928/14,928, zero skips" holds only under the hermetic lane filter. A
|
||||
raw `dotnet test AcDream.slnx` shows 74 skips and 36 failures across five
|
||||
assemblies; re-run in isolation with the repo filter, all five pass
|
||||
(7,560 / 0). Fine — but the headline should say "hermetic lanes".
|
||||
d. The Track A report repeats the findings doc's wrong premise that
|
||||
`TerrainUtils.GetNormal` produced "the faceted look". That function only
|
||||
orients scenery (`SceneryGenerator.cs:166`). The rendered mesh already had
|
||||
smooth central-difference normals (Phase 3b). The real change —
|
||||
central-difference → retail's split-aware incident-face average — is a
|
||||
genuine, decomp-verified parity port (0x00531700: sum unit plane normals
|
||||
per vertex id, normalise, `(0,0,1)` fallback), but a subtler visual change
|
||||
than either document implies. The reviewer owns the original error.
|
||||
|
||||
### F6 — LOW (architecture): the pack API is heavier than what it can express
|
||||
|
||||
42 public types in `AcDream.Plugin.Abstractions.Rendering`.
|
||||
`RenderPassSemantic` is the built-in pack's pass list (BloomDownsample,
|
||||
BloomBlur×2, SunOcclusion, SunRays, VolumetricShafts, FilmicComposite) plus
|
||||
`CustomFullscreen`. A third-party pack can swap shaders within the built-in
|
||||
pipeline and add fullscreen passes; it cannot introduce a new pass kind. That
|
||||
is a sensible v1 (Iris/OptiFine are fixed programs with named slots) — the
|
||||
docs should describe it that way rather than as a general framework. The
|
||||
SPIR-V validator living in the BCL-only abstractions assembly is unusual but
|
||||
lets the SDK validator run without App/Vulkan references. The built-in pack
|
||||
registers through `BufferedRenderPackRegistry.Register(descriptor, assets)`
|
||||
exactly like externals — good.
|
||||
|
||||
### F7 — LOW (perf/architecture): the shadow pass keeps its own transform buffer
|
||||
|
||||
`DirectionalShadowTransformBufferSet` uploads a second matrix buffer. The
|
||||
matrices are recomposed from the same `MeshRef.PartTransform × LocalToWorld`
|
||||
through the same `WbDrawDispatcher.ComposePartWorldMatrix`, so there is no
|
||||
second pose (constraint 5 honoured in spirit), but there is a second
|
||||
composition and upload per animated part per frame, and this buffer is where
|
||||
the 65,536-matrix ceiling bug lived. Measured cost is small (0.11–0.21 ms CPU
|
||||
incremental). A future GPU-culling step will want one shared buffer.
|
||||
|
||||
### F8 — LOW (quality)
|
||||
|
||||
- `atmospheric_volumetric.frag` marches up to 64 steps with no per-pixel
|
||||
jitter → visible banding at Low/Medium step counts.
|
||||
- The sun-occlusion mask is "depth ≈ far plane" only; no disc/sky-radiance
|
||||
weighting, so rays are uniform-coloured.
|
||||
- `SkyDescLoader.ResolveSortCenter` swallows exceptions to `Vector3.Zero`;
|
||||
acceptable for enhancement metadata, but note it against the silent-catch
|
||||
rule. The other 26 added `catch` sites are cleanup-then-rethrow or
|
||||
convert-to-failure-outcome, consistent with the fail-safe contract.
|
||||
|
||||
---
|
||||
|
||||
## What is good (specifically)
|
||||
|
||||
- **Receiver shader** (`directional_shadow_receiver.glsl`): bias in world
|
||||
metres (constant + slope-scaled + normal offset), scaled per cascade by
|
||||
texel density — the #129 NDC-bias trap is handled by construction; true
|
||||
compare-then-interpolate PCF via `textureGather`; cascade blend band and
|
||||
terminal reach fade in metres so FOV cannot move seams.
|
||||
- **Cascade fitter**: bounding-sphere per split, radius quantised to 1/16 m,
|
||||
centre snapped to the light-space texel grid — stable CSM done right.
|
||||
- **Caster pass**: per cascade, one terrain MDI plus the opaque/cutout MDI
|
||||
runs over the **full resident set**; no per-cascade CPU reculling, with
|
||||
classification counters so the gate is enforced rather than hoped.
|
||||
Alpha-cutout casters sample and discard. Low uses multiview.
|
||||
- **Indoor gating**: `IsOutdoor = RenderSky && !CameraInsideCell`.
|
||||
- **Lifecycle**: off-side candidate preparation, atomic activation, withdrawal
|
||||
at a frame boundary, no-retry per registration, Auto with 180-sample
|
||||
hysteresis and a visible reason string. Device loss is correctly treated as
|
||||
terminal to the device lifetime.
|
||||
- **Boundaries**: `git diff main..HEAD -- src/AcDream.Runtime src/*/Physics`
|
||||
is empty; Core edits are additive (`TranslucencyFadeManager.Revision`,
|
||||
`SkyObjectData.AuthoredSortCenter`); Headless references no pack types.
|
||||
- **#226 blend**: `src = (d.rgb·f, d.a·f)` under `DSTCOLOR + INVSRCALPHA` gives
|
||||
`dest × (1 + f·(d.rgb − d.a))` — exactly neutral at f=0, retail's measured
|
||||
factor at f=1. The dead `BuildingDetailTextures` checkbox is now live
|
||||
without a second option.
|
||||
- **A2**: the decomp claim is correct and the port matches it (unit face
|
||||
normals summed per shared vertex, per-landblock like retail).
|
||||
- **Tests**: green under the repo's hermetic filter — 7,560/0 in the five
|
||||
assemblies re-run in isolation, plus Runtime 1,818, Core 4,877,
|
||||
UI.Abstractions 884, Content 160, MossTank 48, RenderPackValidator 30.
|
||||
|
||||
---
|
||||
|
||||
## Performance summary
|
||||
|
||||
Reference adapter (RX 9070 XT, 1080p capped, fixed dense 9,498-caster scene):
|
||||
Low 0.108 ms CPU incr / 0.90 ms GPU incl / 40 MiB; Medium 0.116 / 1.00 / 74;
|
||||
High 0.121 / 1.24 / 114; volumetric +0.19 ms GPU. Credible and inside the
|
||||
declared budgets. Caveats: one high-end adapter; the integrated-AMD row proves
|
||||
only safe fallback; the connected dense-town figures carry the observer tax
|
||||
(F5b); the default path's own pre/post cost is unmeasured (F1).
|
||||
|
||||
---
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. F1 — run both checks (pixel gate vs `6c79d35c`; clean production A/B).
|
||||
Blocking for merge.
|
||||
2. F2 — decide: drop the ramp (retail) or register it. Small.
|
||||
3. F3 — one cdb read on the retail client. Small; also settles the
|
||||
reviewer's earlier finding.
|
||||
4. F4 — linear-light post stack as a follow-up slice.
|
||||
5. F5 — correct the four sentences in the plan/reports.
|
||||
6. Owner visual gate per the plan's own final-gate list; then ship.
|
||||
Loading…
Add table
Add a link
Reference in a new issue