research(vm0): Campaign AR's pack-off path IS the pre-campaign renderer - PASS

Exact-pixel and production-perf comparison of 6c79d35c (+ only the A2
normal files, so no terrain mask is needed) against c51b07ef with no pack:
connected as +Acdream, visible window, one isolated config clone per
variant, pinned clocks. Open field: the only differences are idle pose,
mana digits and a passing flyer. Holtburg: same-binary-twice defines the
dynamic mask (9.8%); in the static 90% both self-diffs have ZERO pixels
with |d|>=8 while base+normals vs HEAD-off has 841/729 - all streaks inside
the animated lifestone. Buildings, ground, trees, sky and UI are clean.

Perf (uncapped Release, no automation observer, ACDREAM_FRAME_PROF=1):
Holtburg CPU p50 4.7 -> 4.1 ms, Arwic 6.0 -> 5.2 ms, GPU unchanged,
alloc/frame 574 KB -> 21 KB. No regression; F5b's '27.8 ms retail CPU' was
the observer.

Three false alarms recorded so nobody repeats them: the isolated gate
settings lack fieldOfView (90 vs the real 86.33 -> a 0.952 zoom); the real
%APPDATA% settings still selected acdream.atmospheric/low (pack ON); a
minimized GLFW window is throttled and never settles.

Tools: tools/vm0/capture-visible.ps1 (pre-campaign gate + -Exe/-Live/
-ConfigDir/-CharacterName/-PreCaptureCommand), tools/vm0/perf-run.sh,
and -BuildingDetailTextures on run-offline-pixel-gate.ps1. Baseline
patches under docs/research/evidence/vm0/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 21:40:40 +02:00
parent 4a69ecd32e
commit 30e72a2af8
12 changed files with 721 additions and 2 deletions

View file

@ -76,7 +76,7 @@ single-pass path darkens by ~10 %. Neither explains a scene-wide level.
| Slice | Closes | Gate |
|---|---|---|
| VM0 | F1 — default-path invariance | automated + Fable report |
| VM0 | F1 — default-path invariance | **CLOSED 2026-08-22 — PASS**; [report](../research/2026-08-22-vm0-default-path-invariance.md) |
| VM1 | F2 — invented detail fade | automated |
| VM2 | F3 — which retail detail path ran | **CLOSED 2026-08-22** — single-pass; see [cdb note](../research/2026-08-22-vm2-retail-detail-path-cdb.md) |
| VM3 | F4 — linear-light post stack | automated + **owner visual** |
@ -85,7 +85,7 @@ single-pass path darkens by ~10 %. Neither explains a scene-wide level.
| 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
Order is VM2 (done) → VM0 (done) → VM1 → VM4 → VM5 → VM3 → VM6 → VM7. VM0 goes first because everything after it is measured
against the baseline it establishes.
---
@ -126,6 +126,8 @@ performance against `6c79d35c`.
6. Add the masked comparison as a repeatable tool mode
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
**OUTCOME (2026-08-22): PASS.** Connected, visible-window, isolated-config captures: base+normals vs HEAD-off has zero strong static differences outside the animated lifestone at Holtburg and the open field; production CPU is 13% cheaper and allocation 25x lower on HEAD's pack-off path (no observer, uncapped Release). Three config traps (isolated FOV, the real Roaming settings still selecting the pack, minimized-window throttling) are recorded in the report. Replaced the planned terrain mask with a base+normals baseline (no mask needed).
**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

View file

@ -0,0 +1,119 @@
# VM0 — is Campaign AR's pack-off path the pre-campaign renderer?
**Date:** 2026-08-22 · **Campaign:** VM slice VM0 · **Status:** CLOSED — PASS
**Question:** with no render pack selected, does `c51b07ef` (Campaign AR) draw
the same pixels and cost the same as `6c79d35c` (pre-campaign main), outside
the two intended parity changes (A2 terrain normals, #226 building detail)?
**Answer:** yes, to the measured noise floor — and the pack-off path is
faster and allocates less.
## Binaries
| Name | Source | Notes |
|---|---|---|
| `base` | `6c79d35c` | + `evidence/vm0/baseline-configdir.patch` (HEAD's 13-line `ACDREAM_CONFIG_DIR/DATA_DIR/CACHE_DIR` hunk in `ApplicationPathSet`, path resolution only) so it can run from an isolated config |
| `base+normals` | `6c79d35c` + `evidence/vm0/baseline-normals.patch` (only `LandblockMesh.cs` + `TerrainVertex.cs` from the campaign) + the config-dir hunk | isolates A2 so the key comparison needs no terrain mask |
| `HEAD` | `c51b07ef` | `BuildingDetailTextures` off and on |
All Release. Worktrees `.claude/worktrees/vm0-base` and `vm0-base-normals`
were throwaway; the two patches reproduce them.
## Method that finally worked (and the three that did not)
Every run: connected to the local ACE as `+Acdream` (name-selected via
`--session-config`; ACE reorders the roster by last login, so an index is not
stable), **visible normal window** (the product condition), one **isolated
config clone** per variant under `artifacts/vm0/cfg-*` with
`renderPack = retail/off`, pinned `ACDREAM_DAY_GROUP=0`,
`ACDREAM_WORLD_TIME=0.5`, `ACDREAM_SKY_PHASE_SECONDS=0`, `ACDREAM_MSAA_SAMPLES=0`,
1280×720, 12 s settle, one screenshot. Helper:
`tools/vm0/capture-visible.ps1` (the `6c79d35c` pixel-gate script with `-Exe`,
`-Live`, `-ConfigDir`, `-CharacterName`, `-PreCaptureCommand`).
Three earlier attempts produced 8891 % pixel differences that were **all
configuration, none renderer** — recorded so nobody repeats them:
1. **Hidden-window captures vs old-tool captures differed by a 0.952 zoom.**
HEAD's gate writes an isolated `settings.json` with no `fieldOfView`, so
HEAD rendered at the default 90° gameFOV while the old binary read the real
file's `86.33°`. Not a renderer change. Consequence: captures from the new
`run-offline-pixel-gate.ps1` and from the pre-campaign tool are **not
comparable** to each other; each tool is self-consistent.
2. **Connected HEAD frames were brighter with tree shadows.** The real Roaming
`settings.json` still held `renderPack = acdream.atmospheric/low` from the
owner's live gate — the pack was ON. (The reviewer had been reading and
editing a stale `%LOCALAPPDATA%\acdream\settings.json`; the client's
Windows config root is `%APPDATA%\acdream`.) Every HEAD run must pin the
selection explicitly.
3. **A minimized HEAD window rendered brighter than hidden/visible.** Windows
throttles an iconified GLFW surface; the 12 s settle had not completed.
Minimized is not a product condition and is not used.
## Pixel results
Exact per-pixel comparison (`max |ΔRGB|`), no tolerance.
**Open field (logout spot, 60.7S 89.1W, few dynamics):**
| Pair | px differing | notes |
|---|---|---|
| base vs base+normals (A2 only) | 12.7 %, mean Δ 1.5 | terrain shading, low amplitude |
| **base+normals vs HEAD-off (KEY)** | **4.3 %, mean Δ 9** | all of it: idle-pose outline, regenerating mana digits, one ambient flyer crossing the camera — ground, trees and UI clean (`diff-I: HEAD-off vs base+normals (KEY).png`) |
| HEAD-off vs HEAD-on (detail) | 3.3 % | no buildings in view; same noise class |
**Holtburg (`/telepoi Holtburg`, buildings, NPCs, animated lifestone):**
the same binary twice differs in 2535 % of pixels by ±17 (sun/time
sub-steps), so a raw count is meaningless. Pixels with |Δ| ≥ 8 between two
logins of the *same* binary define the dynamic mask (lifestone, NPCs, particle
emitter, radar blips, digits; 9.8 % of the frame after a 6 px dilation). In
the remaining 90.2 %:
| Pair | |Δ| ≥ 8 px in static region | max |
|---|---|---|
| base+normals self (two logins) | **0** | 7 |
| HEAD-off self (two logins) | **0** | 7 |
| **base+normals vs HEAD-off (KEY, run 1)** | **841 (0.10 %)** | 128 |
| **base+normals vs HEAD-off (KEY, run 2)** | **729 (0.09 %)** | 128 |
| base vs base+normals (A2 only) | 7,884 (0.95 %) | 212 |
| HEAD-off vs HEAD-on (detail) | 513 (0.06 %) | 255 |
The 841/729 KEY pixels are streaks inside the animated lifestone crystal that
escaped the mask dilation (`evidence/vm0/holtburg-KEY-static-strong-diff.png`);
buildings, roofs, ground, trees, sky and every UI element are clean. The A2
row is the expected shading change on terrain. The detail row is below the
threshold on building shells because the fallback blend's mean factor is
1.033 (+3 %); VM1's single-pass re-port (10 %) will make it measurable.
Evidence: `docs/research/evidence/vm0/*.png`, full captures under
`artifacts/vm0/` (not committed).
## Production performance (no observer)
`tools/vm0/perf-run.sh`: connected, **uncapped Release**, no
`ACDREAM_AUTOMATION_ARTIFACT_DIR` (so no render-scene observer),
`ACDREAM_FRAME_PROF=1`, same isolated config, 75 s at each spot; last six
5-second `[frame-prof]` windows (`evidence/vm0/perf-frame-prof.txt`):
| Spot | Binary | CPU p50 / p95 / p99 | GPU p50 / p95 | alloc KB/frame | Gen0 per 5 s |
|---|---|---|---|---|---|
| Holtburg | base | 4.7 / 5.1 / 5.5 ms | 0.4 / 0.5 | 574 | 1213 |
| Holtburg | **HEAD off** | **4.1 / 4.4 / 4.8 ms** | 0.4 / 0.5 | **21** | 01 |
| Arwic (dense) | base | 6.0 / 6.5 / 7.0 ms | 0.7 / 0.8 | 582 | 10 |
| Arwic (dense) | **HEAD off** | **5.2 / 5.6 / 6.1 ms** | 0.7 / 0.9 | **29** | 01 |
No regression. The pack-off path is ~13 % cheaper on CPU with ~25× less
managed allocation per frame; GPU unchanged. This also settles review F5b:
the "27.8 ms retail-path CPU" in the Stage-2 connected report was the
automation observer, not the product.
## Verdict
VM0 PASSES. Campaign AR's default path is the pre-campaign renderer plus the
two declared parity changes. The F1 blocker is cleared.
## Carried forward
- The new pixel gate's isolated settings should carry `fieldOfView` (and
`gamma`) so its captures mean the same as the product's — filed for VM7's
tool tidy; not a renderer issue.
- `run-offline-pixel-gate.ps1` gained `-BuildingDetailTextures` (VM0).

View file

@ -0,0 +1,31 @@
diff --git a/src/AcDream.Platform/ApplicationPathSet.cs b/src/AcDream.Platform/ApplicationPathSet.cs
index a6743ad1..76e3a1f5 100644
--- a/src/AcDream.Platform/ApplicationPathSet.cs
+++ b/src/AcDream.Platform/ApplicationPathSet.cs
@@ -72,6 +72,16 @@ public sealed record ApplicationPathSet(
{
platform ??= ApplicationPathEnvironment.Instance;
+ // Explicit method/CLI arguments remain authoritative. The environment
+ // seam lets graphical automation and portable installations isolate
+ // all mutable user state without rewriting the real user's settings.
+ configDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_CONFIG_DIR"));
+ dataDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_DATA_DIR"));
+ cacheDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_CACHE_DIR"));
+
string config;
string data;
string cache;
@@ -140,6 +150,9 @@ public sealed record ApplicationPathSet(
return Path.Combine(root, leaf);
}
+ private static string? NonEmpty(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value;
+
private static string RequireFolder(
IApplicationPathEnvironment platform,
Environment.SpecialFolder folder)

View file

@ -0,0 +1,149 @@
diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs
index 81e67249..e3a09fca 100644
--- a/src/AcDream.Core/Terrain/LandblockMesh.cs
+++ b/src/AcDream.Core/Terrain/LandblockMesh.cs
@@ -56,27 +56,21 @@ public static class LandblockMesh
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
- // doubles as the source for per-vertex normals via central differences
- // (Phase 3b lighting, preserved through the per-cell refactor).
+ // is also the source for retail's topology-aware vertex normals.
var heights = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
- // Pre-compute all 81 vertex normals so the inner cell loop is a pure
- // lookup. Central differences on the heightmap → smooth normal field.
- var normals = new Vector3[HeightmapSide, HeightmapSide];
- for (int x = 0; x < HeightmapSide; x++)
- for (int y = 0; y < HeightmapSide; y++)
- {
- int xL = Math.Max(x - 1, 0);
- int xR = Math.Min(x + 1, HeightmapSide - 1);
- int yD = Math.Max(y - 1, 0);
- int yU = Math.Min(y + 1, HeightmapSide - 1);
- float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
- float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
- normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
- }
+ // Retail CLandBlockStruct::calc_lighting accumulates the normalized
+ // plane normal of every incident terrain polygon at each of the 81
+ // shared height-sample vertices, then normalizes the sum. Use the same
+ // split hash and triangle topology as the emitted mesh; this changes
+ // lighting only, never positions, indices, or the collision surface.
+ var normals = BuildRetailVertexNormals(
+ heights,
+ landblockX,
+ landblockY);
var vertices = new TerrainVertex[VerticesPerLandblock];
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
@@ -173,6 +167,85 @@ public static class LandblockMesh
return new LandblockMeshData(vertices, indices);
}
+ private static Vector3[,] BuildRetailVertexNormals(
+ float[,] heights,
+ uint landblockX,
+ uint landblockY)
+ {
+ var normalSums = new Vector3[HeightmapSide, HeightmapSide];
+
+ for (int cy = 0; cy < CellsPerSide; cy++)
+ {
+ for (int cx = 0; cx < CellsPerSide; cx++)
+ {
+ var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
+ var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
+ var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
+ var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
+
+ var split = TerrainBlending.CalculateSplitDirection(
+ landblockX, (uint)cx, landblockY, (uint)cy);
+
+ if (split == CellSplitDirection.SWtoNE)
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ else
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTL, cx, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ }
+ }
+
+ var normals = new Vector3[HeightmapSide, HeightmapSide];
+ for (int x = 0; x < HeightmapSide; x++)
+ {
+ for (int y = 0; y < HeightmapSide; y++)
+ {
+ Vector3 sum = normalSums[x, y];
+ normals[x, y] = sum.LengthSquared() > 0f
+ ? Vector3.Normalize(sum)
+ : Vector3.UnitZ;
+ }
+ }
+
+ return normals;
+ }
+
+ private static void AccumulateFaceNormal(
+ Vector3[,] normalSums,
+ Vector3 p0, int x0, int y0,
+ Vector3 p1, int x1, int y1,
+ Vector3 p2, int x2, int y2)
+ {
+ Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0);
+ if (cross.LengthSquared() <= 0f)
+ return;
+
+ Vector3 faceNormal = Vector3.Normalize(cross);
+ normalSums[x0, y0] += faceNormal;
+ normalSums[x1, y1] += faceNormal;
+ normalSums[x2, y2] += faceNormal;
+ }
+
private static void WriteCell(
TerrainVertex[] verts, ref int vi,
uint d0, uint d1, uint d2, uint d3,
diff --git a/src/AcDream.Core/Terrain/TerrainVertex.cs b/src/AcDream.Core/Terrain/TerrainVertex.cs
index a031e837..69775c23 100644
--- a/src/AcDream.Core/Terrain/TerrainVertex.cs
+++ b/src/AcDream.Core/Terrain/TerrainVertex.cs
@@ -11,11 +11,11 @@ namespace AcDream.Core.Terrain;
/// which of the 4 cell corners a given vertex represents from
/// <c>gl_VertexID % 6</c> plus the split direction bit.
///
-/// Normal is stored per vertex via Phase 3b's central-difference scheme on
-/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
-/// normal across triangles (softer than WorldBuilder's <c>dFdx</c>/<c>dFdy</c>
-/// flat-shaded approach). UVs are derived from the corner index in the
-/// vertex shader — not stored here.
+/// Normal is stored per vertex using retail's terrain-lighting rule: each
+/// shared height-sample vertex receives the normalized plane normals of its
+/// incident, split-aware terrain triangles and normalizes their sum. The
+/// fragment shader interpolates that smooth result across triangles. UVs are
+/// derived from the corner index in the vertex shader — not stored here.
///
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
/// </summary>

View file

@ -0,0 +1,31 @@
import sys, numpy as np
from PIL import Image
root = r"artifacts/vm0"
def load(n): return np.asarray(Image.open(f"{root}/{n}/screenshots/world-offline.png").convert("RGB")).astype(np.int16)
def cmp(a, b, name, maskTop=0):
A, B = load(a), load(b)
assert A.shape == B.shape, (A.shape, B.shape)
d = np.abs(A - B).max(axis=2)
H, W = d.shape
region = d[maskTop:, :]
diff = region > 0
n = int(diff.sum()); tot = region.size
rows = np.where(diff.any(axis=1))[0]
print(f"{name}: {n} / {tot} px differ ({100.0*n/tot:.5f}%)"
+ (f" rows {rows.min()+maskTop}-{rows.max()+maskTop}, max|d|={int(region.max())}, mean|d| over differing={region[diff].mean():.2f}" if n else " IDENTICAL"))
if n:
hist = np.bincount(region[diff].astype(np.int64), minlength=8)
print(f" |d| histogram 1..7+: {hist[1:8].tolist()} ...>=8: {int((region[diff]>=8).sum())}")
vis = np.zeros((H, W, 3), np.uint8)
vis[..., 0] = np.clip(d * 16, 0, 255).astype(np.uint8) # red = difference amplified x16
base = (A.astype(np.float32) * 0.25).astype(np.uint8)
vis = np.maximum(vis, base)
Image.fromarray(vis).save(f"{root}/diff-{name}.png")
return n
print("== whole frame, exact ==")
cmp("base", "base-normals", "A2-normals-only")
cmp("base-normals", "head-off", "HEAD-off_vs_base+normals")
cmp("head-off", "head-on", "detail-on_vs_off")
cmp("base", "head-off", "HEAD-off_vs_base")
print("== top-280 sky band masked (the tool's documented self-noise band) ==")
cmp("base-normals", "head-off", "HEAD-off_vs_base+normals_masked", 280)

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

View file

@ -0,0 +1,4 @@
holtburg-base: p50=4.7 p95=5.1 p99=5.8 max=8.8 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=600.3 gc=12/3/0;p50=4.7 p95=5.0 p99=5.3 max=6.1 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=689.8 gc=13/4/0;p50=4.7 p95=5.0 p99=5.5 max=7.7 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=689.8 gc=12/4/0;p50=4.7 p95=5.0 p99=5.3 max=6.2 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=689.8 gc=12/3/0;p50=4.7 p95=5.1 p99=5.4 max=6.4 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=623.1 gc=13/4/0;p50=4.7 p95=5.1 p99=5.8 max=10.1 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=574.4 max=692.6 gc=12/3/0;
holtburg-head: p50=4.1 p95=4.4 p99=4.7 max=6.5 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=47.2 gc=1/0/0;p50=4.1 p95=4.4 p99=4.7 max=5.5 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=139.2 gc=1/0/0;p50=4.2 p95=4.4 p99=4.6 max=5.7 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=156.5 gc=0/0/0;p50=4.1 p95=4.4 p99=4.7 max=5.3 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=220.9 gc=1/0/0;p50=4.1 p95=4.4 p99=5.5 max=9.0 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=67.0 gc=0/0/0;p50=4.2 p95=4.4 p99=4.6 max=5.4 | gpu_ms p50=0.4 p95=0.5 | alloc_kb p50=20.9 max=136.3 gc=1/0/0;
arwic-base: p50=6.0 p95=6.5 p99=7.0 max=8.9 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=646.5 gc=10/3/0;p50=6.0 p95=6.4 p99=6.8 max=7.5 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=697.7 gc=9/3/0;p50=6.0 p95=6.5 p99=7.0 max=7.6 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=700.7 gc=10/3/0;p50=5.9 p95=6.4 p99=7.0 max=11.5 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=700.9 gc=10/4/0;p50=6.0 p95=6.4 p99=7.0 max=11.7 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=620.8 gc=10/3/0;p50=6.0 p95=6.5 p99=7.1 max=7.9 | gpu_ms p50=0.7 p95=0.8 | alloc_kb p50=582.5 max=732.5 gc=10/3/0;
arwic-head: p50=5.1 p95=5.5 p99=5.9 max=6.5 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.9 max=68.1 gc=1/0/0;p50=5.2 p95=5.6 p99=6.3 max=8.4 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.8 max=143.9 gc=1/0/0;p50=5.1 p95=5.5 p99=6.1 max=9.6 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.9 max=143.9 gc=0/0/0;p50=5.1 p95=5.5 p99=5.9 max=6.3 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.9 max=144.1 gc=1/0/0;p50=5.2 p95=5.6 p99=6.6 max=12.4 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.8 max=68.2 gc=1/0/0;p50=5.2 p95=5.5 p99=5.9 max=6.6 | gpu_ms p50=0.7 p95=0.9 | alloc_kb p50=28.8 max=144.1 gc=0/0/0;

View file

@ -113,6 +113,11 @@
acdream's normal default. A shallow positive angle can put a low sun in
frame for ray and volumetric-shaft captures.
.PARAMETER BuildingDetailTextures
Retail Building Detail Textures preference written into the isolated
settings (#226). Default $true (the product default). $false captures the
pack-off frame without the building/EnvCell detail replay, which is how
Campaign VM slice VM0 isolates that parity change from the rest.
.PARAMETER Uncapped
Disable both VSync and the normal refresh-rate software limiter. Omit for
the capped product cadence. This is a diagnostic measurement mode only.
@ -155,6 +160,10 @@ param(
[int]$RenderPackSampleTimeoutMs = 300000,
[switch]$AllowSafeRenderPackFallback,
[switch]$Uncapped,
# Campaign VM slice VM0: pin the retail Building Detail Textures preference
# (#226). Default keeps the product default (on). Pass $false to capture the
# pack-off frame without the building/EnvCell detail replay.
[bool]$BuildingDetailTextures = $true,
[switch]$SkipBuild
)
@ -202,6 +211,7 @@ $settings = [ordered]@{
resolution = $Resolution
fullscreen = $false
vsync = $false
buildingDetailTextures = $BuildingDetailTextures
renderPack = [ordered]@{
packId = $packId
packVersion = $packVersion

View file

@ -0,0 +1,352 @@
# Campaign VM slice VM0 capture helper. The pre-campaign pixel gate (6c79d35c)
# with three changes: an explicit -Exe so one script drives every binary under
# test, a VISIBLE normal window (the real product condition - a minimized GLFW
# surface is throttled by Windows and HEAD's hidden borderless mode changes the
# framing, see docs/research/2026-08-22-vm0-default-path-invariance.md), and an
# optional -ConfigDir for binaries that honour ACDREAM_CONFIG_DIR.
<#
.SYNOPSIS
Campaign V self-differential pixel gate. Captures deterministic offline
screenshots and optionally compares them against a baseline capture.
.DESCRIPTION
Every Campaign V renderer-port slice must prove it changed no pixels. The
connected lifecycle gate needs a live ACE server and the user's attention;
this gate needs neither. The client is launched WITHOUT ACDREAM_LIVE, so it
renders the world straight from the DATs, no session is created, and no ACE
state can be disturbed.
Coverage: terrain, terrain blending, scenery, static world meshes, sky and
fog, and the full retained UI (vitals, spell bar, toolbar, chat, radar).
It does NOT cover EnvCell interiors, particles, or the paperdoll viewport
those still need the connected route, so slices touching them (V4e, V4g)
must additionally be gated by the user.
Determinism levers, all three forced here rather than left to the ambient
environment, because a capture that inherits any of them is not comparable
against one that did not:
* ACDREAM_DAY_GROUP -- the sky day group, so weather and the keyframe
do not drift between runs.
* ACDREAM_WORLD_TIME -- the Dereth day fraction. This moves the SUN, so
it moves every lit surface, not just the sky. Slice V7 measured 22.3% of
the frame differing between two captures 45 s apart at one stop on one
backend with this unpinned.
* ACDREAM_SKY_PHASE_SECONDS -- the cloud sheet's UV scroll, which runs on
wall time and is independent of the world clock by design.
ACDREAM_MSAA_SAMPLES is forced to 0 because multisample resolve positions
are explicitly unspecified across implementations, and at 4x they swamp
the threshold ninety-fold with two drivers' sample patterns. See plan
section 5.5.16.
A fixed warm-up lets streaming settle before the frame is captured.
.PARAMETER Out
Directory to write this capture into. Created if absent, emptied if present.
.PARAMETER Baseline
Optional. A directory from a previous capture. When supplied, every PNG is
compared against its namesake and the script fails on any mismatch.
.PARAMETER WarmupMs
Milliseconds to let the world stream and settle before capturing. Default
12000, which reliably reaches a fully populated view on the reference machine.
.PARAMETER DayGroup
Sky day-group index to pin. Default 0.
.PARAMETER Tolerance
Per-channel absolute difference allowed. Default 2, matching the project's
pinned screenshot rule.
.PARAMETER MaxDifferentFraction
Fraction of differing pixels allowed. Default 0.001, likewise pinned.
.PARAMETER WorldDayFraction
Dereth day fraction pinned on the launch, in [0, 1). Default 0.5 -- noon, the
brightest and flattest lighting the day has.
.PARAMETER SkyPhaseSeconds
Elapsed-seconds value the sky's UV scroll is pinned to. Default 0, the cloud
sheet's authored origin.
.PARAMETER MsaaSamples
MSAA sample count forced on the launch. Default 0 -- off, which is what plan
section 2's pixel-acceptance contract specifies. Pass -1 to leave the quality
preset alone.
.PARAMETER MaskTopPixels
Height in pixels of the sky and treeline band excluded from comparison.
Default 280.
This is not a fudge factor, and slice V10 re-measured it rather than assuming
the V7 clock pins had retired it. Two captures at the SAME commit once
differed in 0.29% of pixels, essentially all of it in the top rows, because
the sky legitimately animates: clouds scroll on wall time and the Dereth
clock moves the sun, and therefore every lit surface. Pinning both clocks --
which this script now does -- shrinks that but does NOT remove it. Measured
at V10, two launches of the same binary with all four levers pinned:
whole frame, GL vs GL 1,011 px of 921,600 (1.10e-03)
whole frame, VK vs VK 482 px of 921,600 (5.23e-04)
top 280 masked, GL vs GL 10 px of 563,200 (1.78e-05)
top 280 masked, VK vs VK 8 px of 563,200 (1.42e-05)
So the band still moves between launches on both backends -- enough that an
unmasked self-differential on GL fails the 0.001 threshold on noise alone --
and everything below it is bit-stable to about ten pixels. Masking the band
is what makes the rest a strict identity check instead of forcing a loose
tolerance that would hide real regressions everywhere else.
Consequence, unchanged: this gate does NOT cover sky rendering.
Do NOT reach for a LARGER value to make a failing gate pass: plan section 7.1
rule 2. Its legitimate use is diagnostic -- reporting a masked/unmasked split
so a known, registered band (for instance AD-46's treeline) can be quantified
separately from the rest of the frame.
.PARAMETER SkipBuild
Skip the Release build (use when the caller already built).
.EXAMPLE
# Capture a baseline at the parent commit, then gate the slice:
git stash
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base
git stash pop
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Baseline,
[int]$WarmupMs = 12000,
[int]$DayGroup = 0,
[double]$WorldDayFraction = 0.5,
[double]$SkyPhaseSeconds = 0,
[int]$MsaaSamples = 0,
[int]$Tolerance = 2,
[double]$MaxDifferentFraction = 0.001,
[int]$MaskTopPixels = 280,
[switch]$SkipBuild,
[Parameter(Mandatory = $true)][string]$Exe,
[string]$ConfigDir,
# Connect to the local ACE (ACDREAM_TEST_HOST/PORT/USER/PASS must already be in the
# environment - never written into this script). Uses the retail chase camera.
[switch]$Live,
# Character NAME to log in as. ACE orders the roster by last login, so an index is
# not stable across consecutive runs; a name is.
[string]$CharacterName = '+Acdream',
# Optional slash command run after entering the world (e.g. '/telepoi Holtburg'),
# followed by a materialization wait, before the warmup and capture.
[string]$PreCaptureCommand
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$exe = $Exe
$cli = Join-Path $repo 'src\AcDream.Cli\bin\Release\net10.0\AcDream.Cli.dll'
function Write-Step($message) { Write-Host "[pixel-gate] $message" }
# --- 1. Build -----------------------------------------------------------------
if (-not $SkipBuild) {
Write-Step 'building Release'
& dotnet build (Join-Path $repo 'AcDream.slnx') -c Release --nologo -v q | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE." }
}
if (-not (Test-Path $exe)) { throw "Client not found at $exe. Build Release first." }
# --- 2. Prepare the capture directory and probe script ------------------------
if (Test-Path $Out) { Remove-Item -Recurse -Force $Out }
New-Item -ItemType Directory -Force -Path $Out | Out-Null
$probe = Join-Path $Out 'offline.probe.txt'
# The script runner reads one command per line. A single settled capture is the
# whole gate: a second stop would need camera movement, which offline has no
# deterministic way to drive.
if ($Live) {
$tele = if ($PreCaptureCommand) { "command $PreCaptureCommand`nwait materialized 1 60000`n" } else { '' }
Set-Content -Encoding utf8 -Path $probe -Value @"
wait world-ready 90000
wait world-visible 30000
${tele}sleep $WarmupMs
screenshot world-offline 30000
sleep 500
"@
} else {
Set-Content -Encoding utf8 -Path $probe -Value @"
sleep $WarmupMs
screenshot world-offline 30000
sleep 500
"@
}
$log = Join-Path $Out 'client.log'
# --- 3. Launch offline --------------------------------------------------------
$previousLive = $env:ACDREAM_LIVE
if ($Live) { $env:ACDREAM_LIVE = '1' } else { Remove-Item Env:\ACDREAM_LIVE -ErrorAction SilentlyContinue }
$env:ACDREAM_DAT_DIR = Join-Path $env:USERPROFILE "Documents\Asheron's Call"
$env:ACDREAM_NO_AUDIO = '1'
if ($ConfigDir) { $env:ACDREAM_CONFIG_DIR = $ConfigDir; $env:ACDREAM_DATA_DIR = (Join-Path $Out 'isolated-data'); $env:ACDREAM_CACHE_DIR = (Join-Path $Out 'isolated-cache') }
else { Remove-Item Env:\ACDREAM_CONFIG_DIR,Env:\ACDREAM_DATA_DIR,Env:\ACDREAM_CACHE_DIR -ErrorAction SilentlyContinue }
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_DAY_GROUP = "$DayGroup"
$env:ACDREAM_UI_PROBE_SCRIPT = $probe
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $Out
# The determinism pins, forced rather than inherited. See .DESCRIPTION.
$invariant = [System.Globalization.CultureInfo]::InvariantCulture
$env:ACDREAM_WORLD_TIME = $WorldDayFraction.ToString($invariant)
$env:ACDREAM_SKY_PHASE_SECONDS = $SkyPhaseSeconds.ToString($invariant)
if ($MsaaSamples -ge 0) { $env:ACDREAM_MSAA_SAMPLES = "$MsaaSamples" }
else { Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue }
Write-Step "launching offline client (warmup ${WarmupMs}ms, day group $DayGroup, day fraction $WorldDayFraction, sky phase $SkyPhaseSeconds, MSAA $MsaaSamples)"
$launchArgs = @()
if ($Live) {
$sessionConfigPath = Join-Path $Out 'graphical-session.json'
[ordered]@{
version = 1
sessions = @([ordered]@{
id = 'vm0-capture'
endpoint = [ordered]@{ host = $env:ACDREAM_TEST_HOST; port = [int]$env:ACDREAM_TEST_PORT }
account = $env:ACDREAM_TEST_USER
character = [ordered]@{ name = $CharacterName }
credential = [ordered]@{ provider = 'Environment'; reference = 'ACDREAM_TEST_PASS' }
})
} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $sessionConfigPath -Encoding utf8
$launchArgs = @('--session-config', ('"' + $sessionConfigPath + '"'))
}
$proc = Start-Process -FilePath $exe -ArgumentList $launchArgs -RedirectStandardOutput $log `
-RedirectStandardError "$log.err" -PassThru -WindowStyle Normal
try {
$shots = Join-Path $Out 'screenshots'
$deadline = (Get-Date).AddMilliseconds($WarmupMs + 60000 + $(if ($Live) { 120000 } else { 0 }))
$captured = $false
while ((Get-Date) -lt $deadline) {
if ((Test-Path $shots) -and (Get-ChildItem $shots -Filter *.png -ErrorAction SilentlyContinue)) {
$captured = $true
break
}
if ($proc.HasExited) { break }
Start-Sleep -Milliseconds 1000
}
if (-not $captured) {
Write-Host (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue)
throw 'No screenshot was captured before the deadline.'
}
# Let the probe script finish its trailing sleep so the PNG is fully flushed.
Start-Sleep -Milliseconds 1500
}
finally {
# Graceful close: WM_CLOSE runs the shutdown path, so the ownership ledger
# converges the way the lifecycle tests expect. No ACE session exists here,
# but keeping the habit means this script is safe to point at a live run too.
$app = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($app) {
$app.CloseMainWindow() | Out-Null
if (-not $app.WaitForExit(10000)) {
Write-Step 'WM_CLOSE timed out; forcing'
$app | Stop-Process -Force
}
}
Remove-Item Env:\ACDREAM_MSAA_SAMPLES -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_WORLD_TIME -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_SKY_PHASE_SECONDS -ErrorAction SilentlyContinue
if ($previousLive) { $env:ACDREAM_LIVE = $previousLive }
}
$captures = Get-ChildItem (Join-Path $Out 'screenshots') -Filter *.png
Write-Step "captured $($captures.Count) screenshot(s) into $Out"
# The offline window is minimised but still focusable, so a stray scroll or key
# press from whoever is at the keyboard can move the camera mid-capture. That
# produces two screenshots of the same scene from different camera positions —
# a huge, entirely spurious pixel difference. This actually happened during
# slice V4b. Detect it and fail loudly rather than let a comparison be
# interpreted as a rendering regression.
$cameraInput = Select-String -Path $log -Pattern 'ScrollUp|ScrollDown|ZoomIn|ZoomOut|CameraZoom' `
-CaseSensitive -ErrorAction SilentlyContinue
if ($cameraInput) {
Write-Host ''
Write-Host '[pixel-gate] ABORTED: camera-affecting input reached the capture window.' -ForegroundColor Red
Write-Host ' The capture is not comparable. Re-run without touching the machine.' -ForegroundColor Red
$cameraInput | Select-Object -First 5 | ForEach-Object { Write-Host " $($_.Line)" -ForegroundColor Red }
exit 2
}
# --- 4. Compare against the baseline -----------------------------------------
if (-not $Baseline) {
Write-Step 'no baseline supplied; capture only'
exit 0
}
if (-not (Test-Path $cli)) { throw "AcDream.Cli not found at $cli." }
# Build the sky mask. The comparer skips any pixel whose mask alpha is non-zero,
# so the band we exclude is opaque and everything compared is transparent.
$maskPath = $null
if ($MaskTopPixels -gt 0) {
Add-Type -AssemblyName System.Drawing
$probeImage = [System.Drawing.Bitmap]::FromFile($captures[0].FullName)
$width = $probeImage.Width
$height = $probeImage.Height
$probeImage.Dispose()
$mask = New-Object System.Drawing.Bitmap($width, $height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($mask)
$graphics.Clear([System.Drawing.Color]::FromArgb(0, 0, 0, 0))
$opaque = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 0, 255))
$graphics.FillRectangle($opaque, 0, 0, $width, [Math]::Min($MaskTopPixels, $height))
$graphics.Dispose()
$opaque.Dispose()
$maskPath = Join-Path $Out 'sky-mask.png'
$mask.Save($maskPath, [System.Drawing.Imaging.ImageFormat]::Png)
$mask.Dispose()
Write-Step "masking the top $MaskTopPixels rows (animated sky and treeline)"
}
$failed = @()
foreach ($shot in $captures) {
$expected = Join-Path $Baseline "screenshots\$($shot.Name)"
if (-not (Test-Path $expected)) {
$failed += "$($shot.Name): no baseline counterpart"
continue
}
$report = Join-Path $Out "compare-$($shot.BaseName).json"
if ($maskPath) {
& dotnet $cli compare-screenshots $expected $shot.FullName $report $Tolerance $MaxDifferentFraction $maskPath | Out-Null
}
else {
& dotnet $cli compare-screenshots $expected $shot.FullName $report $Tolerance $MaxDifferentFraction | Out-Null
}
$verdict = Get-Content $report -Raw | ConvertFrom-Json
$fraction = $verdict.differentPixelFraction
if ($null -eq $fraction) { $fraction = $verdict.DifferentPixelFraction }
$passed = $verdict.passed
if ($null -eq $passed) { $passed = $verdict.Passed }
if ($passed) {
Write-Step "PASS $($shot.Name) (differing fraction $fraction)"
}
else {
$failed += "$($shot.Name): differing fraction $fraction exceeds $MaxDifferentFraction (report: $report)"
}
}
if ($failed.Count -gt 0) {
Write-Host ''
Write-Host '[pixel-gate] FAILED:' -ForegroundColor Red
$failed | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Step 'all screenshots match the baseline'
exit 0

21
tools/vm0/perf-run.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Campaign VM slice VM0 production perf probe: connected, uncapped Release, NO automation
# artifact dir (so no render-scene observer), reads the client's own [frame-prof] lines.
# usage: perf-run.sh <name> <exe> <configdir> <outdir> [slash-command]
set -u
name=$1; exe=$2; cfg=$3; out=$4; cmd=${5:-}
mkdir -p "$out/$name"; log="$out/$name/client.log"; probe="$out/$name/probe.txt"
sc="$out/$name/session.json"
cat > "$sc" <<JSON
{"version":1,"sessions":[{"id":"vm0-perf","endpoint":{"host":"$ACDREAM_TEST_HOST","port":$ACDREAM_TEST_PORT},"account":"$ACDREAM_TEST_USER","character":{"name":"+Acdream"},"credential":{"provider":"Environment","reference":"ACDREAM_TEST_PASS"}}]}
JSON
{ echo "wait world-ready 90000"; echo "wait world-visible 30000"; [ -n "$cmd" ] && { echo "command $cmd"; echo "wait materialized 1 60000"; }; echo "sleep 75000"; } > "$probe"
ACDREAM_DAT_DIR="$USERPROFILE/Documents/Asheron's Call" ACDREAM_CONFIG_DIR="$cfg" ACDREAM_DATA_DIR="$out/$name/data" ACDREAM_CACHE_DIR="$out/$name/cache" \
ACDREAM_NO_AUDIO=1 ACDREAM_DAY_GROUP=0 ACDREAM_WORLD_TIME=0.5 ACDREAM_SKY_PHASE_SECONDS=0 ACDREAM_MSAA_SAMPLES=0 ACDREAM_UNCAPPED_RENDER=1 ACDREAM_FRAME_PROF=1 \
ACDREAM_UI_PROBE_SCRIPT="$probe" "$exe" --session-config "$sc" > "$log" 2>&1 &
pid=$!
# wait for the probe to finish (75 s after arrival), then graceful close
for i in $(seq 1 200); do grep -q "UI probe script complete" "$log" 2>/dev/null && break; sleep 1; done
pwsh -NoProfile -Command '$p=Get-Process -Name AcDream.App -ErrorAction SilentlyContinue; if($p){$p.CloseMainWindow()|Out-Null; if(-not $p.WaitForExit(10000)){$p|Stop-Process -Force}}' >/dev/null
wait $pid 2>/dev/null
echo "== $name"; tr -d '\r' < "$log" | grep "frame-prof" | tail -8 | sed 's/^.*\[frame-prof\]/ /' | cut -c1-150