From e77dd7c413e10517b89d9e3b367fd4177c9de354 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 10:28:59 +0200 Subject: [PATCH] docs: launch-options reference + the test that keeps it honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client reads 161 ACDREAM_* environment variables across 79 files. Only about 25 were written down, and the audit found the documentation drifting in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL / ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the jump fallback is 300, not the documented 200), while flags with real side effects had no description at all. docs/launch-options.md documents every one by lifecycle — production, command line, measurement, automation, permanent diagnostics, temporary probes, deprecated, retired — with a mandatory side-effects column. That column is the point: #432 cost three days of taxed measurements because ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently measures a streaming window production never uses. Rows now say so. Other surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in a duplicate scenery-placement path rather than only logging, ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist. LaunchOptionsDocumentationTests enforces it, because a hand-maintained list of 161 flags is stale within a week: an undocumented flag fails, and so does a documented row whose read site was deleted. It scans string literals rather than GetEnvironmentVariable call shapes — the startup path reads through an injected delegate, so a call-shaped pattern silently missed ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third test freezes per-file direct-read debt by exact count (20 files outside the owner classes), so structure rules 4 and 5 can be paid down but not regressed. CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its stale test-character paragraph is corrected. Also fixed, all doc-vs-code mismatches the audit proved: - RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable that Campaign V slice V11 deleted. - Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died with the OpenGL backend. - EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the method had no caller anywhere and its documented caller never existed. Filed rather than fixed, to keep this a documentation change: #434 (the DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable" comments are false and 35 env reads are unreachable) and #435 (17 temporary probes outlived their closed investigations; 14 more name no owner). Full hermetic suite 12,202 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 125 ++---- docs/ISSUES.md | 89 +++++ docs/README.md | 6 + docs/launch-options.md | 362 ++++++++++++++++++ .../Rendering/Gpu/Vk/VulkanBringUpHost.cs | 6 +- .../Rendering/Wb/EnvCellRenderer.cs | 55 --- src/AcDream.App/RuntimeOptions.cs | 11 +- .../Rendering/RenderingDiagnostics.cs | 10 +- .../LaunchOptionsDocumentationTests.cs | 245 ++++++++++++ 9 files changed, 744 insertions(+), 165 deletions(-) create mode 100644 docs/launch-options.md create mode 100644 tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d99f6db2..808da717 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1555,108 +1555,35 @@ governed by whether the previous shutdown was graceful or forced. ### Test character `+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has -basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default -200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate` -for local motion prediction. **These are NOT synced to the server** — -ACE's own character data is authoritative for broadcast motion. If you -see a speed/anim mismatch between local and observer views, the fix is -to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired -via `PlayerMovementController.ApplyServerRunRate`) or from -`PlayerDescription (0x0013)`. +basic stats. Run/jump skills arrive FROM the server and drive local motion +prediction (`LiveMovementStatsApplier` → `PlayerMovementController`); the +hardcoded fallbacks before the server speaks are 200 run / 300 jump. The +former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no +longer exist — see the Retired section of +[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim +mismatch between local and observer views, check the server sync path +(`UpdateMotion.ForwardSpeed` echo via +`PlayerMovementController.ApplyServerRunRate`, or +`PlayerDescription (0x0013)`). ### Diagnostic env vars -- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid, - stance, cmd, speed) + resulting `SetCycle` call. Massive for remote- - animation debugging. -- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override - (`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the - shipped radii come from the quality preset - (`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring - inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only - ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is - silently discarded by any later Settings `ApplyQuality` - (`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it - unset for any measurement or gate run** — with it set you are measuring a - different window than production. Per-axis overrides - `ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`) - are the modern spelling. -- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe - (`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal - gate to landblock radius N instead of the derived streaming window, so the - same binary can run a route once with the pre-#280 behaviour (`=1`) and once - without. Not a user setting; not surfaced in Settings; not persisted. - Values below 1 are rejected by the parser: an outdoor acknowledgement with - `RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape` - invariant, so `=0` would hang the route it is meant to measure. -- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver- - broken setups. -- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion - diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, - `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, - `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. -- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per - `PhysicsEngine.ResolveWithTransition` call: input + target + output - position/cell, ok-vs-partial, grounded-in, contact-plane status, - wall normal if hit, **responsible entity guid**, env flag, walkable - polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via - the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`. -- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per - `PlayerMovementController.CellId` change: old → new cell, world - position, reason tag (`resolver` / `teleport`). Low volume — only - fires on actual cell crossings. Runtime-toggleable via the same - DebugPanel section. -- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics - tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call), - `[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch), - `[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit). - Heavy under motion (~100–500 lines/sec). Pair with retail's cdb - breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture - protocol. Runtime-toggleable via the DebugPanel. -- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility - decisions at frame boundaries. Used to converge the U.4c flap fix - (root indoor visibility at player's cell, not eye). -- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]` - lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown), - per-armed-tick steer lines (signed gap dist, applied delta, heading - delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site. - Heavy while a pack is stuck (~60 Hz × stuck count). Converged the - #171 residuals (the deep-overlap sign pin AP-82). -- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the - collision geometry where the visual geometry is?** (#337, TEMPORARY). - `[support]`: one line per resolve **for every body, not just the player** - (a corpse falling through geometry is the cheapest control there is on - "movement code vs geometry data"). It samples the outdoor terrain - independently at the body's own out-XY and prints the contact plane's own - height at that same XY, so `support=terrain` / `object` / `none` is a - measurement rather than an inference; `cpSrc=` names the site that wrote - the plane so provenance cross-checks the classification. Edge-eager, - throttled to 4 Hz per body, and emits every 10 cm of vertical movement. - `[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex - cloud against its visual mesh AABB in the same frame, with a verdict - (`coincident` REFUTES "collision isn't where the visual is"; - `no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch` - each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate - those cases — it carries no plane normal, no plane height, no terrain - sample and no provenance. -- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from - a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan) - beside the same objects' visual mesh boxes (magenta) and the terrain - surface (yellow). Settles "visual versus collision" by eye instead of by - log. `ACDREAM_WIRE_RADIUS=` sets the window (default 30). - TEMPORARY, with the #337 probe family. -- `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side - `PhysicsEngine.ResolveWithTransition` call. Each call appends one - JSON Lines record with full inputs, PhysicsBody snapshot before AND - after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag - — NPC / remote DR calls don't pollute. Pairs with the trajectory - replay harness comparison tests to diff captured vs harness state - per field — the first divergence pinpoints missing apparatus state. - Capture is OFF when the env var is unset (one null-check cost per - call). -- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump - resolved cell/GfxObj polygon tables as JSON when ids cache. Used - for harness fixture extraction. +Every environment variable and command-line argument the client reads — +what it does, its exact value shape, and **what else it changes about the +run** — is documented in +[`docs/launch-options.md`](docs/launch-options.md). That file is the single +source of truth and is enforced by `LaunchOptionsDocumentationTests`: a +flag without a documented row fails the build, and so does a documented row +whose read site was deleted. + +Two habits that list exists to enforce: + +- **Read the side-effects column before any measurement.** Flags that look + inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame + diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a + streaming window production never uses. +- **A temporary probe dies with its investigation.** Add the row when you + add the probe; delete both in the commit that fixes the issue. ### Outbound motion wire format (acdream → ACE) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 80164fc7..d9866cfb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,95 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #435 — Probe debt: 17 temporary probes outlived their closed investigations, 14 more name no owner + +**Status:** OPEN +**Severity:** LOW (no runtime defect; hot-path clutter and measurement noise) +**Filed:** 2026-08-24 (measured during the launch-options audit) +**Component:** diagnostics ownership + +**Measurement (2026-08-24, `docs/launch-options.md`):** the client reads 161 +`ACDREAM_*` variables. 64 are temporary probes. They cite 21 distinct +issues, of which **14 are already closed** — 17 probe rows are apparatus +whose investigation ended without the strip: + +| Closed issue | Probes that outlived it | +|---|---| +| #337 | `ACDREAM_PROBE_SUPPORT`, `ACDREAM_WIRE_MESH`, `ACDREAM_WIRE_RADIUS` | +| #119 | `ACDREAM_DUMP_ENTITY`, `ACDREAM_PROBE_VIEWER` | +| #32 | `ACDREAM_PROBE_REMOTE_LANDING` | +| #42 | `ACDREAM_AIRBORNE_DIAG` | +| #63 | `ACDREAM_PROBE_AUTOWALK` | +| #78 | `ACDREAM_PROBE_SHELL` | +| #83 | `ACDREAM_PROBE_WALK_MISS` | +| #105 | `ACDREAM_PROBE_TEXFLUSH` | +| #113 | `ACDREAM_PROBE_PHANTOM` | +| #131 | `ACDREAM_PROBE_OUTSTAGE` | +| #133 | `ACDREAM_PROBE_LIGHT` | +| #171 | `ACDREAM_PROBE_STICKY` | +| #334 | `ACDREAM_PROBE_REACH` | +| #338 | `ACDREAM_PROBE_STEP_HEIGHTS` | + +A further **14 temporary rows name no owning issue at all**, which is worse: +nothing records when they become safe to delete. + +**Why it matters:** each probe leaves a branch on its hot path even unset, +several re-read the environment per call rather than caching +(`ACDREAM_WB_DIAG` on every `Draw()`, `ACDREAM_DUMP_SURFACES` every render +frame until it fires, `ACDREAM_AIRBORNE_DIAG` per airborne resolve), and +the sheer count makes the real diagnostic surface hard to find. This is +also a correctness risk for headless: `HeadlessStaticStateAudit` +reflects over `PhysicsDiagnostics`' probe flags and refuses a multi-session +host when any is set, but it cannot see the probes that live outside that +owner class. + +**Fix shape:** per probe, confirm its issue is closed and no gate script +references it, then delete flag + read sites + doc row together. The +launch-options row is the checklist — `LaunchOptionsDocumentationTests` +fails if a row survives its read site, so the doc cannot drift during the +cleanup. Do NOT bulk-delete: a few (e.g. `ACDREAM_DUMP_ENTITY`) are +consumed by a second, unrelated probe (`ACDREAM_PROBE_OUTSTAGE` reuses its +id list), so deletion order matters. + +--- + +## #434 — The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live + +**Status:** OPEN +**Severity:** LOW (no runtime defect; a documentation-truth and dead-code problem) +**Filed:** 2026-08-24 (found during the launch-options audit) +**Component:** UI.Abstractions / diagnostics ownership + +**Symptom:** nothing in `src/` ever constructs `DebugPanel` or `DebugVM` +(`src/AcDream.UI.Abstractions/Panels/Debug/`). Their ImGui frontend was +deleted at Campaign V slice V11 — `SettingsDevToolsComposition.cs:13` +says so explicitly. Only `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` +instantiates them. Consequences: + +1. The ~35 `ACDREAM_*` reads inside `DebugPanel.cs`/`DebugVM.cs` are + unreachable in production (they only initialize the mirror state of a + panel that never exists). The flags themselves stay live — every one is + also read by its diagnostics owner — so no launch option is lost. +2. **Every "runtime-toggleable via the DebugPanel" claim in + `PhysicsDiagnostics`/`RenderingDiagnostics` XML docs is false.** Those + flags are startup-only today. `docs/launch-options.md` deliberately does + not repeat the claim; the owner-class comments still do. +3. `DebugVM` is still referenced as a TYPE by + `LiveCombatAttackOperations.Bind/Unbind` and + `DebugVmRenderFactsPublisher`, so those bind paths can never receive a + real instance. + +**Why not fixed with the audit:** deleting the pair is a real refactor +(test project churn plus two live type references), not a documentation +edit. Kept separate deliberately. + +**Fix shape:** either delete `DebugPanel`/`DebugVM` with their tests and +the two dead bind seams, or re-host them on the retained retail UI. Then +sweep the owner-class doc comments for "runtime-toggleable" and either +delete the claim or make it true. Decide which before touching either. + +--- + ## #433 — Stale entities from OTHER landblocks visible ("hanging in the air") after portal travel or /ls near Holtburg **Status:** OPEN diff --git a/docs/README.md b/docs/README.md index d8ab77b4..71558c70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,6 +86,12 @@ document in the same change; do not leave both claims standing. pipeline, the self-hosted runners, and how alpha releases are published. Load-sensitive tests live in `Lane=Timing`; see [`release-gate.md`](release-gate.md) before adding to it. +- [`launch-options.md`](launch-options.md) is the SSOT for every environment + variable and command-line argument the client reads, including what each one + changes about the run beyond its obvious effect. Read the side-effects column + before trusting any measurement. Enforced by + `LaunchOptionsDocumentationTests`: a flag without a row fails the build, and + so does a row whose read site was deleted. - [`audit/`](audit/) contains completion and conformance audits. - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local ACE server's complete in-game command catalog and points to the authoritative diff --git a/docs/launch-options.md b/docs/launch-options.md new file mode 100644 index 00000000..de8c528b --- /dev/null +++ b/docs/launch-options.md @@ -0,0 +1,362 @@ +# acdream launch options — operator reference + +Every environment variable and command-line argument the acdream client +reads, what it does, and **what else it changes about the run**. + +**This is an operator's reference, not user documentation.** Players never +set these: the launcher owns installation and login, and the in-client +Options panel (F11) owns settings. If a flag here looks like something a +player would want, that is a signal it belongs in the Options panel, not a +signal to document it better. + +## How to use this document + +- **Running the client for yourself?** Read *Production launch* and stop. +- **Taking a measurement?** Read *Production launch*, then read the + *Side effects* column of every flag you are about to set. A flag that + changes what you are measuring is the normal case, not the exception. +- **Adding a flag?** Add its row in the same commit. `LaunchOptionsDocumentationTests` + fails the build otherwise — in both directions, so deleting a read site + without deleting its row fails too. + +### Why the side-effects column exists + +Two flags in this list were believed to be inert and were not: + +- `ACDREAM_AUTOMATION_ARTIFACT_DIR` reads like an output path. It also + constructs a per-frame diagnostics referee that re-enabled a retired + render pass, costing ~6 MB and ~14 ms **every frame** — three days of + performance measurements were silently taxed before anyone noticed + ([#432](ISSUES.md)). +- `ACDREAM_STREAM_RADIUS` reads like a radius knob. It forces the near + radius, only ever *raises* the far radius, and is then silently + discarded by any later quality apply — so a measurement taken with it + set is measuring a window production never uses. + +Assume a flag has a side effect until its row says otherwise. + +## Conventions + +- `=1` means the code tests for exactly the string `1`. Setting `true`, + `yes`, or `0` does **not** enable such a flag (and `0` does not disable + one whose test is "is the variable present"). +- **Default** is the behavior when the variable is unset. +- **Kind** is one of: + +| Kind | Meaning | +|---|---| +| `production` | Ordinary configuration; safe in a real run. | +| `measurement` | Profiling/instrumentation. Read the side effects before trusting numbers taken with it on. | +| `automation` | Drives scripted runs; usually implies extra machinery. | +| `permanent-probe` | A diagnostic toggle owned by a subsystem's diagnostics class. Expected to persist. | +| `temporary-probe` | Tied to an open investigation. Deleted with its issue — never build tooling on one. | +| `deprecated` | Superseded. Do not use for new work. | + +--- + +## Production launch + +The canonical connected launch against a local ACE server. PowerShell, +because the DAT path contains an apostrophe: + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_RETAIL_UI = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release +``` + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) | +| `ACDREAM_AC_DIR` | `=` | Points at a real retail AC install dir; loads `/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir` → `InteractionRetainedUiComposition.cs:610` | +| `ACDREAM_ANISOTROPIC` | `=` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) | +| `ACDREAM_CACHE_DIR` | `=` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` | +| `ACDREAM_CAMERA_COLLIDE` | `=0` disables (anything else/unset = on) | selects whether the chase camera sweeps a 0.3 m collision sphere from head-pivot to eye and stops at the first wall (retail spring-arm) | alters camera position every frame (camera can clip into geometry when disabled); startup-only | true (on) | `CameraDiagnostics.CollideCamera` | +| `ACDREAM_CONFIG_DIR` | `=` | Overrides the resolved config-root directory (`settings.json`, `keybinds.json`) | none beyond redirecting config I/O | Windows: `%APPDATA%\acdream`; Linux: `$XDG_CONFIG_HOME/acdream` or `~/.config/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:79`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_DATA_DIR` | `=` | Overrides the resolved data-root directory (logs, screenshots, plugins) | none beyond redirecting data I/O | Windows: `%LOCALAPPDATA%\acdream`; Linux: `$XDG_DATA_HOME/acdream` or `~/.local/share/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:81`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_DAT_DIR` | `=` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) | +| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) | +| `ACDREAM_FAR_RADIUS` | `=` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) | +| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating | +| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) | +| `ACDREAM_MSAA_SAMPLES` | `=` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) | +| `ACDREAM_NEAR_RADIUS` | `=` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) | +| `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) | +| `ACDREAM_PAK_PATH` | `=` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` | +| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) | +| `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) | +| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) | +| `ACDREAM_RESIDENCY_AUDIO_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained audio-buffer cache | Shrinking can force more frequent audio buffer re-decode/eviction | 32 MiB | `ResidencyBudgetOptions.Parse` (`:85-87`), consumed by `ContentEffectsAudioComposition.cs` | +| `ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB` | `=` (`>0`, else default) | Byte ceiling for physically-resident composite (character palette/texture) GPU memory | Changes composite-texture eviction pressure — not representative of production if varied during a measurement run | 128 MiB | `ResidencyBudgetOptions.Parse` (`:67-69`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned/retained (not currently referenced) composite textures kept for reuse | Same eviction-pressure caveat | 64 MiB | `ResidencyBudgetOptions.Parse` (`:70-72`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_MESH_GPU_MIB` | `=` (`>0`, else default) | Byte ceiling for GPU-resident object mesh data | The single largest residency budget (1024 MiB default) — shrinking it directly forces more mesh re-upload/eviction; do not vary during an FPS/GPU-memory measurement run | 1024 MiB | `ResidencyBudgetOptions.Parse` (`:49-51`), consumed by `ObjectMeshManager.cs`/`WbDrawDispatcher.cs` | +| `ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the mesh upload staging cache | Changes staging-buffer churn/eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:64-66`) | +| `ACDREAM_RESIDENCY_MESH_STAGING_MIB` | `=` (`>0`, else default) | Byte ceiling for the mesh upload staging cache | Same staging-churn caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:61-63`) | +| `ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned (retained-for-reuse) object mesh entries | Changes mesh-cache eviction cadence | 50 | `ResidencyBudgetOptions.Parse` (`:52-54`) | +| `ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the CPU-side "prepared mesh" cache (post-classification, pre-upload) | Changes eviction cadence for prepared-mesh CPU memory | 100 | `ResidencyBudgetOptions.Parse` (`:58-60`) | +| `ACDREAM_RESIDENCY_PREPARED_MESH_MIB` | `=` (`>0`, else default) | Byte ceiling for the CPU-side prepared-mesh cache | Same eviction-cadence caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:55-57`) | +| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned standalone (non-composite) texture entries | Changes standalone-texture eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:76-78`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) | +| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` | +| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades` → `DatLiveEntityProjectionMaterializer.cs:275-276,480-498` | +| `ACDREAM_RETAIL_UI` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` | +| `ACDREAM_TEST_HOST` | `=` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) | +| `ACDREAM_TEST_PASS` | `=` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) | +| `ACDREAM_TEST_PORT` | `=` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) | +| `ACDREAM_TEST_USER` | `=` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) | +| `ACDREAM_VULKAN_DEVICE` | `=` (decimal index) or `=` (case-insensitive device-name match) | Overrides automatic Vulkan physical-device selection (normally: discrete > integrated > virtual > CPU, tie-broken by device-local heap size) — for multi-GPU machines. | A bare-digits value is matched as an index ONLY (never falls through to substring match) specifically because digits like `"7"` are substrings of real device names ("AMD Radeon RX 9070 XT") — a fallback would silently select the wrong device by coincidence. An override matching nothing falls back to the automatic choice (does not fail startup) and records why in the capability report. | `null` → automatic ranked choice | `RuntimeOptions.VulkanDeviceOverride` → `VulkanPhysicalDeviceSelection.Choose` (`VulkanPhysicalDeviceSelection.cs:54-100`), consumed at `VulkanGraphicsContext.cs:207,322` | + +## Command-line arguments + +### `AcDream.App` + +| Arg | What it does | Side effects | +|---|---|---| +| `` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. | +| `--session-config ` | The launcher's launch path: endpoint, account, credential reference, character selector, status file, plugins, login commands. | **Overrides `ACDREAM_LIVE` and every `ACDREAM_TEST_*`** (logged at startup). Diagnostic flags stay env-controlled. Missing value is a startup error. | + +### `AcDream.Headless` + +Its usage banner matches the parser exactly. `validate` loads and checks a +config without connecting; `run` connects. + +| Arg | What it does | Side effects | +|---|---|---| +| `validate` \| `run` (positional) | Selects the mode; must be the first argument. | Anything else is a parse error. | +| `--config ` | The versioned headless session-configuration document. Required. | — | +| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. | +| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. | +| `--help` / `-h` (or no args) | Prints usage, exits 0. | — | + +### `AcDream.Launcher` + +| Arg | What it does | Side effects | +|---|---|---| +| `--verify-publish` | Packaging smoke probe: parses arguments and exits 0 without opening a display or resolving user paths. | — | +| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. | +| `--update-manifest-uri ` | Points the self-updater at a different release manifest (test-feed seam). | Must be `https://` (or loopback `http://`). Changes where updates come from — do not point a real install at a test feed. | +| `--acdream-self-update-helper-v1`, `--acdream-self-update-confirm-v1` | Internal re-exec markers for the self-update handoff. | Not user-facing; never pass these by hand. | + +### `AcDream.Cli` + +A dat-dump and measurement tool dispatched by a positional subcommand +(`args[0]`); no `--flag` options. Most subcommands take a dat directory and +fall back to `ACDREAM_DAT_DIR`. + +- **Measurement:** `summarize-frame-history `, + `compare-screenshots [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`, + `probe `. +- **Dat inspection:** no subcommand (asset-type inventory), `dump-vitals-bars`, + `dump-vitals-layout [0xLayoutId]`, `list-ui-layouts [0xRootType]`, + `dump-sprite-sheet <0xId,...>`, `dump-font-atlas [0xFontId] [sample] [outBase]`, + `dump-edges <0xId>`, `export-ui-sprite <0xId> [out.png]`. +- **Mockup rendering:** `render-vitals-mockup [out.png]`, `mock-selbar [out.png]`, + `crop `. + +## Measurement and profiling + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_CAPTURE_RESOLVE` | `=` | appends one JSON-Lines record (full before/after `PhysicsBody` snapshot) per player-side `ResolveWithTransition` call, filtered to `IsPlayer` movers | real per-tick allocation (snapshot object graph + `System.Text.Json` serialize) and buffered file I/O (`AutoFlush=false`) for the local player only; will skew any perf measurement of local-player physics while active; feeds `CellarUpTrajectoryReplayTests` fixtures | unset (off) | `AcDream.Core.Physics.PhysicsResolveCapture` (`CapturePath`) | +| `ACDREAM_COLLISION_SHADOW_DIR` | `=` | output directory for Slice I5 graph/flat collision-shadow mismatch artifacts | only takes effect when `ACDREAM_COLLISION_SHADOW_EVERY>0`; directory creation + file writes on mismatch | `/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` | +| `ACDREAM_COLLISION_SHADOW_EVERY` | `=` | when >0 and the cache is constructed with `requirePreparedCollision:false`, arms a `CollisionShadowVerifier` that re-runs the graph-vs-flat collision referee every Nth traversal entry (`PhysicsDataCache` ctor) | extra CPU on sampled ticks + mismatch-artifact file I/O; graph path stays authoritative regardless of mismatch (doc-asserted, not independently verified here) — does not change production physics results, but does add work when active | `0` (disabled) | `PhysicsDiagnostics.CollisionShadowSampleEvery` (parsed via `ParsePositiveInt`, non-positive → 0) | +| `ACDREAM_DAY_GROUP` | `=` | Forces Dereth's day-group (weather preset) selection instead of the retail hash-based pick, "useful for visually A/B-testing each weather preset against retail" (own doc comment). | **Dead second read**: the `SkyDescLoader.cs:252` raw read only feeds `SelectDayGroupIndex`, which is only called from `ActiveDayGroup(double)` and the `DefaultDayGroup` property — and grepping all of `src/` finds **zero production call sites** for either. That whole path is unreachable; only the typed `RuntimeOptions.ForcedDayGroupIndex` → Runtime path is live. Bounds differ too: the typed path only checks `>= 0` (`TryParseNonNegativeInt`) and Runtime clamps out-of-range to `null`; the dead Core-layer path checks `forced >= 0 && forced < DayGroups.Count` directly. `SkyState.cs:400,403` are doc-comment mentions only, not reads. | unset → normal server/date-driven hash selection | `RuntimeOptions.ForcedDayGroupIndex` (typed) → `GameWindow.cs:718` → `WorldEnvironmentController` → `RuntimeWorldEnvironmentState` (Runtime, live path); **also** raw `Environment.GetEnvironmentVariable` at `SkyDescLoader.cs:252` (Core layer, separate parse) | +| `ACDREAM_DISABLE_TIER1_CACHE` | `="1"` (ordinal exact match; anything else = enabled) | A/B diagnostic that forces **every** static (non-animated) entity through the slow per-entity classification path, bypassing the Tier-1 classification cache (`#53`) | Materially changes per-frame CPU cost for entity classification — a perf/FPS measurement taken with this set is NOT representative of production and must not be compared against a normal run | unset (cache enabled) | `WbDrawDispatcher` ctor field `_tier1CacheDisabled` (`WbDrawDispatcher.cs:473-474`) | +| `ACDREAM_FRAME_HISTORY` | `=` | opts into a per-frame CSV history capture (frame idx, timestamps, per-stage CPU us, GPU us, alloc bytes) alongside the aggregated 5 s `[frame-prof]` report | allocates a `List` with ~131,072-record (~9 MiB) initial capacity, growing further for longer captures (~72 B/record, ~43 MB/hour at 165 fps) held in memory for the whole run; CSV write happens ONLY at `Dispose`/shutdown (no frame-thread I/O); only takes effect while `ACDREAM_FRAME_PROF` is ALSO on | unset (off) | `RenderingDiagnostics.FrameHistoryPath` / `AcDream.App.Diagnostics.FrameProfiler` | +| `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition | +| `ACDREAM_ORBIT_PITCH_DEGREES` | `=`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` | +| `ACDREAM_ORBIT_YAW_DEGREES` | `=`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` | +| `ACDREAM_PROBE_REVEAL_RADIUS` | `==1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | +| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | +| `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | +| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) | +| `ACDREAM_STREAM_WORK_CPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) | +| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) | +| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) | +| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) | +| `ACDREAM_STREAM_WORK_GPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) | +| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) | +| `ACDREAM_STREAM_WORK_MS` | `=` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) | +| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering` → `GameWindow.cs:765` → `DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` | +| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` | +| `ACDREAM_WORLD_TIME` | `=`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction` → `GameWindow.cs:720` → `WorldEnvironmentController` → `Runtime.WorldTime.PinnedDayFraction` | + +## Automation + +A scripted route run adds three things at once — a session config so the +client self-selects a character, a route script, and an artifact directory: + +```powershell +$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt" +$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts" +$env:ACDREAM_FRAME_PROF = "1" +$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" +& $exe --session-config "$scratch\session.json" +``` + +**Two traps this recipe exists to document:** + +1. **Without `--session-config`, the client stops at character select** and + the route never runs. The session JSON supplies the endpoint, account, + and a character `index` for auto-selection. +2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the + render-scene oracle, which fingerprints every resident entity every + frame. The allocation cost was fixed in + [#432](ISSUES.md), but the CPU walk remains — automation-run frame + rates are diagnostics-loaded and must only be compared against other + automation runs, never against a plain run. Some route verbs + (`wait world-*`) additionally do nothing unless this is set. + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353` — `AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory` → `FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` | +| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer` → `GameWindow.cs:852` (`CreateStartupWindowOptions`) | +| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` | +| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) | +| `ACDREAM_NET_DROP_PCT` | `=` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` | +| `ACDREAM_NET_DROP_SEED` | `=` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) | +| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster<55-slot ghost gate, so this flag is now a bypass of that gate for automation/testing rather than the only way in. | `false` | `RuntimeOptions.OpenCharacterCreationOnStart` → `CharacterCreationUiController.cs:21,524-530` | +| `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump` → `LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` | +| `ACDREAM_UI_PROBE_SCRIPT` | `=` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript` → `InteractionRetainedUiComposition.cs:1094` | +| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | +| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | Its class doc (`VulkanBringUpHost.cs:11`) is stale — claims it additionally requires `ACDREAM_RENDER_BACKEND=vulkan`, which is no longer read anywhere (see that flag's row); in the current code this flag ALONE gates entry (`GameWindow.cs:828: if (_options.VulkanCapabilityProbe)`). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | +| `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | + +## Permanent diagnostics + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) | +| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` | +| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class | +| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 | +| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` | +| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) | +| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) | +| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` | +| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` | +| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` | +| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` | +| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` | +| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` | +| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | +| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | +| `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 | +| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized) with elapsed ms, 1 Hz progress, and one `SUMMARY` line at viewport reveal | print-only; doc comment: "never constructed unless the probe env is set, changes no behavior, and costs one branch per `Evaluate` poll otherwise" (i.e. genuinely near-zero cost when off — confirmed, `RevealTimingProbe` object itself is null when disabled) | unset (off) | `StreamingDiagnostics.ProbeRevealTiming` (`StreamingDiagnostics.cs:65-66`), consumed by `LandblockPresentationPipeline.cs:69`, `PublicationTimingProbe.cs:34`, `RevealTimingProbe.cs` (construction gated) | +| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | +| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | +| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | +| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) | + +## Temporary probes + +Each row names the issue that owns it. **A temporary probe is deleted in +the same commit as its investigation's fix** — if you find one here whose +issue is closed, the strip was missed; delete both. + +> **Probe debt, measured 2026-08-24:** 64 temporary probes exist. They cite +> 21 distinct issues, and **14 of those are already closed** — 17 rows below +> are apparatus whose investigation ended without the strip. A further 14 +> rows name no owning issue at all, which is worse: nobody can tell when +> they are safe to remove. Tracked as [#435](ISSUES.md); do not add to the +> pile. Every probe here still costs a branch on its hot path even when +> unset, and a handful re-read the environment per frame rather than +> caching (see their side-effects column). + +| Flag | Owning investigation | Value | What it does | Side effects | Read by | +|---|---|---|---|---|---| +| `ACDREAM_A8_DUMP_PV` | (unattributed) | `=1` | Dumps local→NDC→clipped portal geometry (first 2 `Build` calls per distinct camera cell) | print-only (`Console.WriteLine`) | `PortalVisibilityBuilder.cs:270-271` (static field, not a diagnostics-owner class) | +| `ACDREAM_AIRBORNE_DIAG` | #42 | `=1` | prints `[SWEEP]`/`[SWEEP-OBJ]` lines tracing airborne-sweep XY drift, only when `!isOnGround` | print-only; re-reads the env var (`Environment.GetEnvironmentVariable`) on every airborne resolve/candidate instead of caching — minor per-call overhead when the flag is unset too | raw reads in `PhysicsEngine.cs:2300` + `TransitionTypes.cs:3905` (issue #42) | +| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | +| `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | +| `ACDREAM_DUMP_CELLS` | #98 | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_CELLS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | `PhysicsDiagnostics.ProbeDumpCellsPath` | +| `ACDREAM_DUMP_CLOTHING` | (unattributed) | `=1` | Print-only: dumps clothing/part-swap diagnostics for a spawned entity when its setup has ≥10 mesh parts (humanoids). Gated additionally on part count even when the flag is on. | `print-only` | `RuntimeOptions.DumpClothing` → `DatLiveEntityProjectionMaterializer.cs:251-258,1190` | +| `ACDREAM_DUMP_EDGE_SLIDE` | (unattributed) | `=1` | gates five `edge-slide:` trace lines (stepdown-failed, stepdown-branch-enter, phase2, branch, cliffslide) inside the L.4-diag edge-slide/cliff-slide code path | print-only; property re-reads `Environment.GetEnvironmentVariable` on EVERY call (not cached in a field) — repeated env lookups during edge-slide resolution when active; raw read outside any diagnostics-owner class (rule-5 candidate) | `Transition.DumpEdgeSlideEnabled` (private expression-bodied property in `TransitionTypes.cs`, raw read) | +| `ACDREAM_DUMP_ENTITY` | #119 | comma-separated hex ids, optional `0x` prefix, malformed segments ignored | per-entity HYDRATE/DRAW/WALK-REJECT trace for a watchlist of Setup/GfxObj source ids across `LandblockBuildFactory`, `WbDrawDispatcher` | print-only; every call site fast-exits on `Count==0`. The same id set is ALSO reused (undocumented in its own XML doc) as the watchlist for the `ACDREAM_PROBE_OUTSTAGE` `[outstage-own]` per-entity verdict probe — see Notes | `RenderingDiagnostics.DumpEntitySourceIds` | +| `ACDREAM_DUMP_GFXOBJS` | #98 | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) | file I/O once per matching id (no-op on repeat) | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_GFXOBJS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | +| `ACDREAM_DUMP_LIVE_SPAWNS` | (unattributed) | `=1` | Print-only: logs every live `CreateObject` spawn as it's processed, plus DROP lines when a setup dat id is missing. | `print-only` | `RuntimeOptions.DumpLiveSpawns` → `DatLiveEntityProjectionMaterializer.cs:161-226`, `SessionPlayerComposition.cs:566,712` | +| `ACDREAM_DUMP_MOVE_TRUTH` | (unattributed) | `=1` | Print-only: records the local player's last outbound movement wire truth (position, cell, contact byte, velocity) for comparing what was actually sent vs. local physics state. Early-returns with zero cost when disabled. | `print-only` | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs:795` → `MovementTruthDiagnosticController` | +| `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. | +| `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` | +| `ACDREAM_DUMP_SKY` | (unattributed) | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | +| `ACDREAM_DUMP_STEEP_ROOF` | (unattributed) | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` | print-only | `PhysicsDiagnostics.DumpSteepRoofEnabled` | +| `ACDREAM_DUMP_STEPUP` | (unattributed) | `=1` | prints `stepup: enter normal=… verdict=WALKABLE/STEEP …` on every step-up attempt | print-only; raw per-call `Environment.GetEnvironmentVariable` read outside a diagnostics-owner class (rule-5 violation); content is mirrored (not replaced) into the buffered `[transit-fail-stepup]` trace gated separately by `ACDREAM_DUMP_TRANSIT_FAIL` | raw read in `Transition.DoStepUp` (`TransitionTypes.cs:5991`, re-read every call — not cached) | +| `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` | +| `ACDREAM_DUMP_VENDOR` | (unattributed) | `="1"` | `[vendor-diag]`-prefixed trace across ~25 call sites for two live-only vendor regressions: Chain A (far-click walk-to-use approach never opens the shop window) and Chain B (splittable vendor stack selection shows no quantity slider) | print-only (`Console.WriteLine`), verified true no-op when unset | `VendorDiagnostics.DumpVendorEnabled` (`VendorDiagnostics.cs:25-26`) — a proper diagnostics-owner class per Code Structure Rule 5, shared across App/Core.Net/Runtime | +| `ACDREAM_DUMP_VITALS` | (unattributed) | `="1"` | Logs every `PrivateUpdateVital(Current)` parse, every parsed `PlayerDescription` (vector flags/attr/spell counts), and `PlayerDescriptionParser` trailer/mid-walk `FormatException` failures with position | print-only at every site. `PlayerDescriptionParser.cs:458/473` re-read the env var raw inside `catch` blocks on every parse failure (rare, but scattered/uncached). | Read independently (not shared) at 4 sites: `WorldSession.cs:790-791` (`DumpVitalsEnabled`), `GameEventWiring.cs:1041` (local `dumpPd` at PlayerDescription registration), `PlayerDescriptionParser.cs:458` and `:473` (per-catch-block raw reads). Also mirrored (display-only, non-functional) via `DebugPanel.cs:240`/`DebugVM.cs:225`. | +| `ACDREAM_HIDE_PART` | (unattributed) | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | +| `ACDREAM_LIGHT_DEBUG` | #176 | `=` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` | +| `ACDREAM_PROBE_AUTOWALK` | issue #63 | `=1` | gates `[autowalk-out]`/`[autowalk-mt]`/`[autowalk-up]` lines in `LiveEntityNetworkUpdateController` tracing local-player server-initiated auto-walk (`SendUse`/`SendPickUp`, inbound `UpdateMotion`, inbound `UpdatePosition`) | print-only; filtered to local player only, low volume | `PhysicsDiagnostics.ProbeAutoWalkEnabled` | +| `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` | +| `ACDREAM_PROBE_CELL` | (unattributed) | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) | print-only; low volume (only on actual cell crossings) | `PhysicsDiagnostics.ProbeCellEnabled` | +| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | +| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | +| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | +| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | +| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` | +| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B | +| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` | +| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` | +| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` | +| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` | +| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` | +| `ACDREAM_PROBE_LIGHT` | #133 a7 | `=1` | rate-limited (1 Hz) `[light]` line + up to 3 `[light-detail]` lines: scene ambient/sun/registered/active light counts and nearest active point/spot light detail | print-only ("Output-only, inert when off" per doc) | `RenderingDiagnostics.ProbeLightEnabled` | +| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` | +| `ACDREAM_PROBE_OUTSTAGE` | #131 | `=1` | print-on-change `[outstage]` line (outside-stage routing + per-slice cone verdicts) from `RetailPViewRenderer`; plus, when `ACDREAM_DUMP_ENTITY` also names watched ids, `[outstage-own]` per-entity PASS/CULL lines | print-only | `RenderingDiagnostics.ProbeOutStageEnabled` | +| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` | +| `ACDREAM_PROBE_PHANTOM` | #113, "throwaway apparatus — strip when the phantom closes" | `=1` | print-on-change `[phantom-shell]` / `[phantom-objs]` lines identifying which draw mechanism (shell pass vs. entity list) draws geometry unclipped/un-viewcone'd per cell | print-only | `RenderingDiagnostics.ProbePhantomEnabled` | +| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` | +| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` | +| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` | +| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` | +| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` | +| `ACDREAM_PROBE_REACH` | #334, temporary — strip with the probe family | `=1` | gates `[reach-q]` (per-cell candidate-disposition query summary, emitted even on zero-entry cells) and `[reach-obj]` (per-candidate disposition: exempt/no-shape/bsp-only-skip/tested) lines in `Transition.FindObjCollisionsInCell` | print-only; runs on a HOT path (per cell per transitional insert); de-duplicated via two `Dictionary` caches with a `lock`-protected gate (`_reachGate`) — a real per-call dictionary lookup + occasional lock contention while on, bounded emission (≤2/sec per cell, ≤1/sec per candidate) | `PhysicsDiagnostics.ProbeReachEnabled` | +| `ACDREAM_PROBE_REMOTE_LANDING` | bug a / issue #32, temporary | `=1` | gates `[remote-landing]`/`[remote-landing-gate]`/`[remote-landing-after]` lines around remote ground-contact edges in `LiveEntityNetworkUpdateController` and `RuntimeRemotePhysicsUpdater`; `MotionTableDispatchSink.ApplyMotion` unconditionally forwards its result to `PhysicsDiagnostics.RecordRemoteLandingDispatch` (self-guarded internally, no behavior change) | print-only; uses `[ThreadStatic]` capture latches (`_remoteLandingApplyCalls` etc.) so a headless host ticking several sessions in parallel doesn't cross-contaminate | `PhysicsDiagnostics.ProbeRemoteLandingEnabled` | +| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | +| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | +| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | +| `ACDREAM_PROBE_SHELL` | #78, "throwaway apparatus — strip once the indoor-enclosure render is fixed" | `=1` | one `[shell]` line per opaque-pass `EnvCellRenderer.Render` call: per filtered cell — snapshot presence, gfxObj/batch/index/translucent/zero-bindless-handle counts | print-only; allocates a `StringBuilder` and loops every visible cell on every opaque pass while enabled | `RenderingDiagnostics.ProbeShellEnabled` | +| `ACDREAM_PROBE_STEP_HEIGHTS` | issue #338 | `=1` | gates edge-triggered `[step-h]` lines at `prepare`/`publish`/`resolve` sites tracing step-up/step-down height provenance | print-only; `AnnounceStepHeightProbeOnce` prints TWO self-report lines EXACTLY ONCE PER PROCESS regardless of the flag's value (reports the flag's own state + the raw env var text + the running assembly's file path) — this self-report line is NOT gated by the flag itself, only rate-limited to once | `PhysicsDiagnostics.ProbeStepHeightsEnabled` | +| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | +| `ACDREAM_PROBE_STICKY` | r5-v3 issue #171 | `=1` | gates `[sticky]` lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown) and per-armed-tick steer lines in `AcDream.Core.Physics.Motion.StickyManager`, plus `[sticky-snap-skip]` in `LiveEntityNetworkUpdateController` when a server hard-snap is suppressed for a stuck entity | print-only; heavy while a pack is stuck (~60 Hz × stuck count) | `PhysicsDiagnostics.ProbeStickyEnabled` | +| `ACDREAM_PROBE_SUPPORT` | issue #337, temporary | `=1` | gates `[support]` (per resolve, per body INCLUDING corpses/NPCs — independent terrain sample at the body's out-XY compared against the contact plane's height/provenance) and `[geom]` (once per nearby GfxObj — physics-BSP vertex cloud vs visual mesh AABB coincidence verdict) lines in `PhysicsEngine` | print-only; `[support]` performs an INDEPENDENT terrain height sample every time it fires (real extra computation beyond the resolve itself, throttled to 4 Hz per body plus every 10 cm of vertical movement); pure reads only, never mutates production collision state | `PhysicsDiagnostics.ProbeSupportEnabled` | +| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | +| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` | +| `ACDREAM_PROBE_TEXFLUSH` | #105 | `=1` | one `[tex-flush]` line whenever `WbMeshAdapter.Tick`'s staged-texture-update picture changes (pending layer updates before/after the per-frame mipmap flush) | print-only | `RenderingDiagnostics.ProbeTexFlushEnabled` | +| `ACDREAM_PROBE_VIEWER` | #119-residual | `=1` | one `[viewer]` line per CHANGE of (root cell, flood size, OutsideView poly count, player cell), with mm-precision projection eye — capture half of the tower-ascent capture→replay loop (`TowerAscentReplayTests`) | print-only | `RenderingDiagnostics.ProbeViewerEnabled` | +| `ACDREAM_PROBE_WALK_MISS` | issues #83, spike-only | `=1` | gates `[walk-miss]` (per `Transition.TryFindIndoorWalkablePlane` MISS) and `[floor-polys]` (per indoor cell cached, enumerating walkable-eligible polygons) lines | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeWalkMissEnabled` | +| `ACDREAM_WIRE_MESH` | #337, explicitly "temporary" | `=1` | when the separate F2 collision-wireframe overlay is already active, replaces its cheap broadphase-proxy-cylinder drawing with the object's REAL physics-BSP polygon edges (cyan) + visual mesh AABB (magenta) + terrain triangle under the player (yellow), resolved live every frame | ALTERS RENDERED OUTPUT (debug overlay geometry): adds real per-frame physics-BSP polygon extraction + line-drawing cost while F2 is on; only takes effect when the separate F2 toggle (`_state.CollisionWireframesVisible`) is also enabled; also emits a throttled print-on-change stats line | `RenderingDiagnostics.CollisionMeshWireframeEnabled` | +| `ACDREAM_WIRE_RADIUS` | companion knob to #337/`acdream_wire_mesh` | `=` (`float.TryParse`, invariant culture; falls back to 30 if unparsable or ≤0) | sets the radius around the player within which `CollisionMeshWireframeEnabled` resolves polygon geometry | larger radius = more physics-BSP polygon extraction/line-drawing cost per frame; only matters while `ACDREAM_WIRE_MESH=1` | `RenderingDiagnostics.CollisionMeshWireframeRadius` | + +## Deprecated + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_DEVTOOLS` | `=1` | logs a one-time "ImGui dev UI removed" notice; the only remaining functional consumer is `VulkanGraphicsContext.cs:184` (`enableOptionalExtensions: _options.DevTools`, selects optional Vulkan validation/debug-utils extensions) | real effect: turns on Vulkan validation/debug-utils extensions (can change perf and can surface validation-layer errors that don't occur when off) — NOT measurement-neutral for a perf gate; `GameWindow.DevToolsEnabled` is a hardcoded `false` const (dead — no ImGui dev UI exists to gate); `DevToolsInputCaptureSource(bool enabled)` explicitly discards its `enabled` ctor arg (`_ = enabled;`) — dead parameter, always reports `WantCaptureKeyboard=false` | off | `RuntimeOptions.DevTools` (typed, `Program.cs`/`RuntimeOptions.Parse`) | +| `ACDREAM_STREAM_RADIUS` | `=` (non-negative) | Legacy override for the streaming near/far radii, applied on top of the quality-preset's radii at session-start composition. | **CLAUDE.md explicitly documents this as "legacy" and warns against using it for measurement.** Confirmed in code (`SessionPlayerComposition.cs:256-259`): `nearRadius = legacyRadius; farRadius = Math.Max(legacyRadius, farRadius)` — it FORCES `NearRadius` and only ever RAISES (never lowers) `FarRadius`. It is set once at session-start composition and is **silently discarded** by any later Settings quality change: `RuntimeSettingsController.ApplyQuality` → `RuntimeSettingsTargets.ApplyQuality` → `StreamingController.ReconfigureRadii` recomputes radii straight from the quality preset with no knowledge of this override. A measurement/gate run taken with this set is measuring a different streaming window than production and than any run that later touches Settings. | `null` → quality-preset radii unmodified (production default: High preset, Near 4 / Far 12) | `RuntimeOptions.LegacyStreamRadius` → `SessionPlayerComposition.cs:254-268` | + +--- + + + +## Retired + +Flags that no longer exist, kept only so a stale script or an old research +document does not send someone hunting. Rows below this marker are exempt +from the "must still exist" check. + +| Flag | Retired | Replacement | +|---|---|---| +| `ACDREAM_RUN_SKILL` | Client-side run-skill override for local motion prediction. Skills now arrive from the server (`LiveMovementStatsApplier`); the hardcoded fallback is 200. | none — server-authoritative | +| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative | +| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none | +| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none | +| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` | diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs index 49a2543b..57470517 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -7,8 +7,10 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering.Gpu.Vk; /// -/// The Vulkan capability-probe and bring-up harness. Reached only when -/// ACDREAM_RENDER_BACKEND=vulkan and ACDREAM_VULKAN_PROBE=1. +/// The Vulkan capability-probe and bring-up harness. Reached when +/// ACDREAM_VULKAN_PROBE=1. (This previously also required +/// ACDREAM_RENDER_BACKEND=vulkan; that variable died with the OpenGL +/// backend at Campaign V and is read nowhere — Vulkan is the only backend.) /// /// What it is for. Answering two questions without starting the /// client: does this machine pass the Vulkan capability gate, and does the RHI diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index a83b7895..16a9e887 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -193,61 +193,6 @@ public sealed partial class EnvCellRenderer : return (poolTotal, hwm); } - /// - /// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up). - /// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes + - /// transparency flags + bindless-handle-non-zero status, so the operator - /// can read offline and identify why specific polys (e.g., floors) aren't - /// rendering. Set ACDREAM_A8_AUDIT=1 to enable. - /// Returns a deduplicated audit-line list per Render snapshot - /// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller - /// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs - /// have already been logged. - /// - public IReadOnlyList CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged) - { - var lines = new List(); - lock (_renderLock) - { - var snap = _activeSnapshot; - foreach (var (cellId, gfxDict) in snap.BatchedByCell) - { - foreach (var (gfxObjId, transforms) in gfxDict) - { - var key = (cellId, gfxObjId); - if (alreadyLogged.Contains(key)) continue; - alreadyLogged.Add(key); - - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd is null) - { - lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null"); - continue; - } - int totalIdx = 0; - var cullModes = new HashSet(); - int translucent = 0; - int additive = 0; - int zeroHandle = 0; - foreach (var b in rd.Batches) - { - totalIdx += b.IndexCount; - cullModes.Add(b.CullMode); - if (b.IsTransparent) translucent++; - if (b.IsAdditive) additive++; - if (!b.TextureSlot.IsAssigned) zeroHandle++; - } - var cullList = string.Join(",", cullModes); - lines.Add( - $"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " + - $"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " + - $"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}"); - } - } - } - return lines; - } - // --------------------------------------------------------------------------- // Constructor // Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 569b551b..d4c0e500 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -215,11 +215,12 @@ public sealed record RuntimeOptions( // can be exercised on hardware that actually supports everything. VulkanForcedUnsupportedFeature: NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")), - // Campaign V slice V6h: with ACDREAM_RENDER_BACKEND=vulkan, run the - // V5/V6c bring-up harness — capability gate plus the synthetic - // verification scenes — instead of the real composition host. A - // diagnostic for "does this machine pass the Vulkan gate, and does - // the backend draw?"; ignored on OpenGL. + // Campaign V slice V6h: run the V5/V6c bring-up harness — + // capability gate plus the synthetic verification scenes — instead + // of the real composition host. A diagnostic for "does this machine + // pass the Vulkan gate, and does the backend draw?". (The former + // ACDREAM_RENDER_BACKEND=vulkan co-requisite died with the OpenGL + // backend at Campaign V; this flag alone gates the harness.) VulkanCapabilityProbe: IsExactlyOne(env("ACDREAM_VULKAN_PROBE")), // Campaign V slice V9: bound the probe harness to a frame budget so diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 4340318b..8c455cf5 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -764,10 +764,12 @@ public static class RenderingDiagnostics /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, /// per-frame allocation counters, reported as one [frame-prof] /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) — - /// do NOT strip with session probes. The whole-frame GPU query is - /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested - /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under - /// that flag — the 2026-06-23 "separate flags" measurement lesson). + /// do NOT strip with session probes. This paragraph previously claimed + /// the whole-frame GPU query self-disables under ACDREAM_WB_DIAG=1; + /// Campaign V slice V11 deleted that self-disable along with the GL query + /// ring it protected, and the two flags are now independent — see + /// FrameProfiler's own class doc. Every backend reports GPU time + /// through FrameProfiler.RecordGpuSample. /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable /// via the DebugPanel mirror (DebugVM.FrameProf). /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs new file mode 100644 index 00000000..32f018dd --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -0,0 +1,245 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Diagnostics; + +/// +/// Keeps docs/launch-options.md honest: every environment variable the +/// shipped client reads must have a documented row, and every documented row +/// must name a variable something actually reads. +/// +/// +/// +/// A hand-maintained list of ~170 flags goes stale within a week. #432 is what +/// that costs: ACDREAM_AUTOMATION_ARTIFACT_DIR read like an output-path +/// setting but also constructed a per-frame diagnostics referee worth ~6 MB and +/// ~14 ms every frame, and three days of measurements were taxed before anyone +/// noticed. The same audit found CLAUDE.md still advertising +/// ACDREAM_RUN_SKILL / ACDREAM_JUMP_SKILL after their read sites +/// were deleted. Both directions of drift are failures, so both fail here. +/// +/// +/// Scope is src/ only. Test-owned variables, shader-compiler macro +/// tokens under tools/, and historical mentions in dated research +/// documents are deliberately out of scope — the doc describes what a launched +/// client reads today. +/// +/// +public sealed class LaunchOptionsDocumentationTests +{ + private const string DocRelativePath = "docs/launch-options.md"; + + /// + /// Structure rule 5 wants runtime flags behind diagnostic owner classes and + /// rule 4 wants startup configuration in RuntimeOptions. These files + /// still read the environment directly, with the exact number of distinct + /// flags each one reads today. + /// + /// + /// The counts are FROZEN, not merely the file names: promoting a stray to + /// its subsystem's owner lowers a number (update it here), and adding a new + /// direct read raises one and fails. A file that reaches zero leaves the + /// table entirely. + /// + private static readonly IReadOnlyDictionary DirectReadDebt = + new Dictionary(StringComparer.Ordinal) + { + ["src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs"] = 2, + ["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1, + ["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1, + ["src/AcDream.App/Rendering/GameWindow.cs"] = 1, + ["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1, + ["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1, + ["src/AcDream.App/Rendering/TextureCache.cs"] = 1, + ["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2, + ["src/AcDream.Core/Physics/PhysicsEngine.cs"] = 1, + ["src/AcDream.Core/Physics/TransitionTypes.cs"] = 3, + ["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1, + ["src/AcDream.Core/World/SkyDescLoader.cs"] = 2, + ["src/AcDream.Core.Net/GameEventWiring.cs"] = 1, + ["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1, + ["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1, + ["src/AcDream.Core.Net/WorldSession.cs"] = 3, + ["src/AcDream.Platform/ApplicationPathSet.cs"] = 3, + ["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1, + ["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1, + ["src/AcDream.UI.Abstractions/Settings/QualityPreset.cs"] = 6, + }; + + /// + /// Any ACDREAM_* string literal in src/. Matching the literal + /// rather than a GetEnvironmentVariable call is deliberate: the + /// startup path reads through an injected env delegate + /// (RuntimeOptions.Parse) so a call-shaped pattern silently missed + /// ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. Comment + /// mentions (shader macros such as ACDREAM_SAMPLE_2D, prefix fragments) + /// carry no quotes and stay out. + /// + private static readonly Regex EnvironmentRead = new( + @"""(ACDREAM_[A-Z0-9_]+)""", + RegexOptions.Compiled); + + private static readonly Regex DocumentedRow = new( + @"^\|\s*`(ACDREAM_[A-Z0-9_]+)`", + RegexOptions.Compiled | RegexOptions.Multiline); + + [Fact] + public void EveryEnvironmentVariableTheClientReadsIsDocumented() + { + IReadOnlySet read = ReadFlags(); + IReadOnlySet documented = DocumentedFlags(); + + List undocumented = read.Except(documented).Order(StringComparer.Ordinal).ToList(); + Assert.True( + undocumented.Count == 0, + $"{DocRelativePath} is missing a row for flags the client reads: " + + string.Join(", ", undocumented) + + ". Add the row in the same commit that adds the read site."); + } + + [Fact] + public void EveryDocumentedEnvironmentVariableStillExists() + { + IReadOnlySet read = ReadFlags(); + IReadOnlySet documented = DocumentedFlags(); + + List phantom = documented.Except(read).Order(StringComparer.Ordinal).ToList(); + Assert.True( + phantom.Count == 0, + $"{DocRelativePath} documents flags nothing in src/ reads: " + + string.Join(", ", phantom) + + ". Delete the row (or move it to the retired section with its " + + "removal commit) in the same commit that deletes the read site."); + } + + [Fact] + public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow() + { + Dictionary actual = SourceFiles() + .Where(file => !IsOwnerClass(file.RelativePath)) + .Select(file => ( + file.RelativePath, + Count: DistinctFlagsRead(File.ReadAllText(file.Path)))) + .Where(file => file.Count > 0) + .ToDictionary(file => file.RelativePath, file => file.Count, StringComparer.Ordinal); + + List problems = []; + foreach ((string path, int count) in actual.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (!DirectReadDebt.TryGetValue(path, out int frozen)) + { + problems.Add( + $"{path} reads {count} ACDREAM_* variable(s) directly but is " + + "not an owner class. Add the property to the subsystem's " + + "diagnostics owner (or RuntimeOptions) and read it there."); + } + else if (count > frozen) + { + problems.Add( + $"{path} grew from {frozen} to {count} direct reads. New " + + "flags belong in an owner class, not here."); + } + } + + foreach ((string path, int frozen) in DirectReadDebt.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + int count = actual.GetValueOrDefault(path, 0); + if (count < frozen) + { + problems.Add( + $"{path} is down to {count} direct read(s) from {frozen}. " + + "Lower the frozen count (or drop the entry at zero) so the " + + "debt cannot silently grow back."); + } + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + private static bool IsOwnerClass(string relativePath) + { + // The intended homes for environment reads: one static diagnostics + // class per subsystem, the typed startup options objects, the + // executables' own entry points, and the single-purpose probe/capture + // owners (a file that exists only to own one probe already satisfies + // the rule the *Diagnostics.cs suffix encodes). + string name = Path.GetFileName(relativePath); + return name.EndsWith("Diagnostics.cs", StringComparison.Ordinal) + || name.EndsWith("Options.cs", StringComparison.Ordinal) + || name.EndsWith("Probe.cs", StringComparison.Ordinal) + || name.EndsWith("Capture.cs", StringComparison.Ordinal) + || name == "Program.cs"; + } + + private static int DistinctFlagsRead(string source) + { + var flags = new HashSet(StringComparer.Ordinal); + foreach (Match match in EnvironmentRead.Matches(source)) + flags.Add(match.Groups[1].Value); + return flags.Count; + } + + private static IReadOnlySet ReadFlags() + { + var flags = new HashSet(StringComparer.Ordinal); + foreach ((string path, _) in SourceFiles()) + { + foreach (Match match in EnvironmentRead.Matches(File.ReadAllText(path))) + flags.Add(match.Groups[1].Value); + } + + Assert.True( + flags.Count > 100, + $"Only found {flags.Count} environment reads in src/; the scanner " + + "is probably broken rather than the codebase suddenly clean."); + return flags; + } + + private static IReadOnlySet DocumentedFlags() + { + string doc = Path.Combine(FindRepoRoot(), DocRelativePath.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(doc), $"{DocRelativePath} is missing."); + + string text = File.ReadAllText(doc); + // Rows below the retired marker describe flags that are deliberately + // gone; they document history and must not resurrect the read-site + // requirement. + int retired = text.IndexOf("", StringComparison.Ordinal); + if (retired >= 0) + text = text[..retired]; + + var flags = new HashSet(StringComparer.Ordinal); + foreach (Match match in DocumentedRow.Matches(text)) + flags.Add(match.Groups[1].Value); + return flags; + } + + private static IEnumerable<(string Path, string RelativePath)> SourceFiles() + { + string root = FindRepoRoot(); + string src = Path.Combine(root, "src"); + foreach (string path in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + if (relative.Contains("/bin/", StringComparison.Ordinal) + || relative.Contains("/obj/", StringComparison.Ordinal)) + { + continue; + } + + yield return (path, relative); + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +}