research(vm0): Campaign AR's pack-off path IS the pre-campaign renderer - PASS
Exact-pixel and production-perf comparison of6c79d35c(+ only the A2 normal files, so no terrain mask is needed) againstc51b07efwith 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:
parent
4a69ecd32e
commit
30e72a2af8
12 changed files with 721 additions and 2 deletions
|
|
@ -76,7 +76,7 @@ single-pass path darkens by ~10 %. Neither explains a scene-wide level.
|
||||||
|
|
||||||
| Slice | Closes | Gate |
|
| 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 |
|
| 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) |
|
| 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** |
|
| 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** |
|
| VM6 | Foliage wind | automated + **owner visual** |
|
||||||
| VM7 | Closeout: full gates, register, roadmap, merge | **owner** |
|
| 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.
|
against the baseline it establishes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -126,6 +126,8 @@ performance against `6c79d35c`.
|
||||||
6. Add the masked comparison as a repeatable tool mode
|
6. Add the masked comparison as a repeatable tool mode
|
||||||
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
|
(`-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
|
**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,
|
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
|
commits and artifact paths. If a regression is found, it is fixed here and
|
||||||
|
|
|
||||||
119
docs/research/2026-08-22-vm0-default-path-invariance.md
Normal file
119
docs/research/2026-08-22-vm0-default-path-invariance.md
Normal 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 88–91 % 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 25–35 % of pixels by ±1–7 (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 | 12–13 |
|
||||||
|
| Holtburg | **HEAD off** | **4.1 / 4.4 / 4.8 ms** | 0.4 / 0.5 | **21** | 0–1 |
|
||||||
|
| 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** | 0–1 |
|
||||||
|
|
||||||
|
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).
|
||||||
31
docs/research/evidence/vm0/baseline-configdir.patch
Normal file
31
docs/research/evidence/vm0/baseline-configdir.patch
Normal 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)
|
||||||
149
docs/research/evidence/vm0/baseline-normals.patch
Normal file
149
docs/research/evidence/vm0/baseline-normals.patch
Normal 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>
|
||||||
31
docs/research/evidence/vm0/compare-offline.py
Normal file
31
docs/research/evidence/vm0/compare-offline.py
Normal 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 |
BIN
docs/research/evidence/vm0/holtburg-KEY-static-strong-diff.png
Normal file
BIN
docs/research/evidence/vm0/holtburg-KEY-static-strong-diff.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 876 KiB |
4
docs/research/evidence/vm0/perf-frame-prof.txt
Normal file
4
docs/research/evidence/vm0/perf-frame-prof.txt
Normal 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;
|
||||||
|
|
@ -113,6 +113,11 @@
|
||||||
acdream's normal default. A shallow positive angle can put a low sun in
|
acdream's normal default. A shallow positive angle can put a low sun in
|
||||||
frame for ray and volumetric-shaft captures.
|
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
|
.PARAMETER Uncapped
|
||||||
Disable both VSync and the normal refresh-rate software limiter. Omit for
|
Disable both VSync and the normal refresh-rate software limiter. Omit for
|
||||||
the capped product cadence. This is a diagnostic measurement mode only.
|
the capped product cadence. This is a diagnostic measurement mode only.
|
||||||
|
|
@ -155,6 +160,10 @@ param(
|
||||||
[int]$RenderPackSampleTimeoutMs = 300000,
|
[int]$RenderPackSampleTimeoutMs = 300000,
|
||||||
[switch]$AllowSafeRenderPackFallback,
|
[switch]$AllowSafeRenderPackFallback,
|
||||||
[switch]$Uncapped,
|
[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
|
[switch]$SkipBuild
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -202,6 +211,7 @@ $settings = [ordered]@{
|
||||||
resolution = $Resolution
|
resolution = $Resolution
|
||||||
fullscreen = $false
|
fullscreen = $false
|
||||||
vsync = $false
|
vsync = $false
|
||||||
|
buildingDetailTextures = $BuildingDetailTextures
|
||||||
renderPack = [ordered]@{
|
renderPack = [ordered]@{
|
||||||
packId = $packId
|
packId = $packId
|
||||||
packVersion = $packVersion
|
packVersion = $packVersion
|
||||||
|
|
|
||||||
352
tools/vm0/capture-visible.ps1
Normal file
352
tools/vm0/capture-visible.ps1
Normal 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
21
tools/vm0/perf-run.sh
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue