Compare commits

..

No commits in common. "main" and "codex/release-stabilization" have entirely different histories.

1271 changed files with 12815 additions and 196349 deletions

View file

@ -1,227 +0,0 @@
# Gitea Actions CI gate for the self-hosted runners.
#
# Deliberately does NOT use actions/setup-dotnet: data.forgejo.org (the mirror
# Gitea resolves actions from) does not host that action at all, and the
# self-hosted runners carry the pinned SDK band from global.json already.
# actions/checkout IS mirrored, so it is used normally.
#
# The suite runs through tools/run-release-gate.ps1 rather than a bare
# `dotnet test`: that script owns the xUnit trait-lane filter which excludes
# the InstalledDat / Live / Manual / OS-specific lanes. A bare `dotnet test`
# fails ~36 tests by design because those lanes assert their own preconditions.
name: CI
on:
push:
branches: [main]
# Docs-only pushes change nothing a test can fail on, and each gate run is
# ~7 minutes of clean build + 14k tests + a 121 MB release. Skip them; a
# code push (or manual dispatch) still runs everything from scratch —
# deliberately uncached, so the gate keeps proving a from-nothing build.
paths-ignore:
- 'docs/**'
- 'claude-memory/**'
- 'memory/**'
- '**.md'
workflow_dispatch:
jobs:
windows-gate:
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Verify the pinned SDK band resolves
shell: pwsh
run: |
dotnet --version
dotnet --list-sdks
# NOT tools/run-release-gate.ps1 here. That script redirects every child
# process to its own log file, so the step emits nothing for minutes at a
# time; Forgejo treats a task that stops reporting as a zombie and fails
# it while the work is still running (observed: job marked failed with 20
# dotnet processes still alive and a complete 8.7 MB TRX on disk). Running
# the projects directly keeps output streaming. The script stays the
# canonical LOCAL gate; the trait filter below is copied from its default.
- name: Build
shell: pwsh
run: dotnet build AcDream.slnx -c Release --nologo
- name: Test (lane-filtered, streaming)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
$failed = @()
foreach ($proj in Get-ChildItem tests -Directory | Sort-Object Name) {
$csproj = Join-Path $proj.FullName "$($proj.Name).csproj"
if (-not (Test-Path $csproj)) { continue }
Write-Host "::group::$($proj.Name)"
dotnet test $csproj -c Release --no-build --nologo --filter $filter
if ($LASTEXITCODE -ne 0) { $failed += $proj.Name }
Write-Host "::endgroup::"
}
if ($failed.Count) { throw "Failed test projects: $($failed -join ', ')" }
linux-portable:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Portable closure (Linux lanes run here, not on Windows)
run: |
set -e
dotnet --version
# Core.Net runs SINGLE-THREADED here, on its own, and the split is
# measured rather than defensive: on this 6-core container the
# assembly FAILS in 40 s with default parallelism and PASSES in 10 s
# with one thread. Its sessions do real socket work on background
# threads, so contention both breaks and slows them. Windows has 18
# cores, passes in ~7 s parallel, and REGRESSED when serialized, so
# this stays scoped to Linux.
echo '::group::AcDream.Core.Net.Tests (single-threaded)'
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj \
-c Release --nologo \
--filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \
-- xUnit.MaxParallelThreads=1
echo '::endgroup::'
for p in \
tests/AcDream.Platform.Tests \
tests/AcDream.Core.Tests \
tests/AcDream.Content.Tests \
tests/AcDream.Runtime.Tests \
tests/AcDream.Headless.Tests \
tests/AcDream.Launcher.Core.Tests \
tests/AcDream.UI.Abstractions.Tests ; do
echo "::group::$p"
dotnet test "$p" -c Release --nologo \
--filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
echo "::endgroup::"
done
release:
# Same workflow rather than a workflow_run trigger: workflow_run is a
# GitHub feature whose Forgejo support is unreliable, while `needs` is
# guaranteed. A red gate therefore cannot publish.
needs: [windows-gate, linux-portable]
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Compute release version
id: ver
shell: pwsh
run: |
$v = '0.1.0-build.{0}' -f ([DateTime]::UtcNow.ToString('yyyyMMddHHmm'))
"version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
Write-Host "release version: $v"
- name: Build payloads with release-attachment URLs
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
run: |
./tools/publish-bin.ps1 -Version $env:TAG -BaseUrl "${{ github.server_url }}/${{ github.repository }}/releases/download/$env:TAG"
- name: Create the release and upload payloads
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
$body = @{
tag_name = $env:TAG
name = "acdream alpha $env:TAG"
body = "Automated alpha build from ${{ github.sha }}."
draft = $false
prerelease = $true
target_commitish = 'main'
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers -ContentType 'application/json' -Body $body
Write-Host "created release id=$($release.id)"
foreach ($f in Get-ChildItem bin -File) {
Write-Host ("uploading {0} ({1:N1} MB)" -f $f.Name, ($f.Length/1MB))
Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($f.Name)" -Form @{ attachment = Get-Item $f.FullName } | Out-Null
}
- name: Republish the `latest` pointer release
shell: pwsh
env:
TAG: ${{ steps.ver.outputs.version }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
# Forgejo has no /releases/latest/download/ route, so the launcher
# needs a pointer at a URL that never changes. A one-asset release on
# the fixed `latest` tag is that pointer. Keeping it in a release
# rather than in git means no payload branch, no bot commits on main,
# and no push that would retrigger this workflow.
$existing = Invoke-RestMethod -Method Get -Headers $headers `
-Uri "$api/releases/tags/latest" -SkipHttpErrorCheck
if ($existing.id) {
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($existing.id)" | Out-Null
# The tag outlives its release and would block recreation.
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/latest" -SkipHttpErrorCheck | Out-Null
Write-Host "removed the previous latest pointer"
}
$body = @{
tag_name = 'latest'
name = "Update feed -> $env:TAG"
body = "**Download ``launcher-win-x64.zip``**, unzip it, and run ``acdream-launcher.exe``. It installs the game and keeps itself and the client up to date.`n`nThis is build ``$env:TAG``."
draft = $false
prerelease = $false
target_commitish = 'main'
} | ConvertTo-Json
$pointer = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers `
-ContentType 'application/json' -Body $body
# Upload the payloads here too, not just the manifest. `latest` is the
# top of the Releases page and the first thing a person sees; a
# pointer-only release gives them nothing to click and makes them hunt
# for a build tagged with a timestamp. The launcher only needs
# manifest.json, but a friend needs launcher-win-x64.zip.
foreach ($f in Get-ChildItem bin -File) {
Invoke-RestMethod -Method Post -Headers $headers `
-Uri "$api/releases/$($pointer.id)/assets?name=$($f.Name)" `
-Form @{ attachment = Get-Item $f.FullName } | Out-Null
}
Write-Host "latest now carries $env:TAG and its downloads"
- name: Prune old releases
shell: pwsh
env:
KEEP: '5'
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$headers = @{ Authorization = "token $env:TOKEN" }
$keep = [int]$env:KEEP
# Each build is ~121 MB of attachments, so without this the server
# grows by that much on EVERY push to main. Keep the newest $keep
# versioned releases: enough to grab a previous build or bisect a
# regression, bounded at well under a gigabyte.
$releases = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases?limit=100"
# Never touch the `latest` pointer — it is the launcher's feed, not a build.
$builds = @($releases | Where-Object { $_.tag_name -ne 'latest' } |
Sort-Object -Property created_at -Descending)
Write-Host "$($builds.Count) versioned release(s); keeping $keep"
foreach ($old in ($builds | Select-Object -Skip $keep)) {
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($old.id)" | Out-Null
# The tag survives its release and would otherwise accumulate.
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/$($old.tag_name)" -SkipHttpErrorCheck | Out-Null
Write-Host " pruned $($old.tag_name)"
}

View file

@ -3,6 +3,9 @@ name: "Copilot Setup Steps"
# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent

View file

@ -1,6 +1,66 @@
name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tests/Fixtures/campaign-la/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Platform/**"
- "src/AcDream.Launcher.Core/**"
- "src/AcDream.Launcher/**"
- "src/AcDream.Bake/**"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "tests/AcDream.Platform.Tests/**"
- "tests/AcDream.Launcher.Core.Tests/**"
- "tests/AcDream.Launcher.Tests/**"
- "tests/AcDream.Bake.Tests/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tests/Fixtures/campaign-la/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:

View file

@ -49,6 +49,9 @@
name: "acdream Hygiene Assessment"
on:
schedule:
- cron: "54 4 * * *"
# Friendly format: daily (scattered)
workflow_dispatch: {}
permissions: {}
@ -1345,3 +1348,4 @@ jobs:
/tmp/gh-aw/safe-output-items.jsonl
/tmp/gh-aw/temporary-id-map.json
if-no-files-found: ignore

View file

@ -1,6 +1,9 @@
name: Complete Release gate
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:

10
.gitignore vendored
View file

@ -2,11 +2,6 @@
bin/
obj/
out/
# NOTE: the repo-root /bin folder holds the alpha distribution feed written by
# tools/publish-bin.ps1. It stays IGNORED here on purpose so a stray `git add`
# can never put ~150 MB of payloads on main (GitHub also hard-rejects any file
# over 100 MB). tools/publish-dist.ps1 force-adds it onto the Gitea-only `dist`
# branch instead, which is what the launcher's update feed reads.
# Rider / VS
.idea/
@ -64,7 +59,6 @@ tmp/
# Disposable dotnet test/build output redirected by local validation runs
.test-out/
**/TestResults/
# Connected-gate, benchmark, and visual-capture artifacts are machine-local
logs/
@ -115,7 +109,3 @@ studio-shots/
# Campaign V capture/evidence output - session-local, never tracked (423 MB lesson, 2026-07-29)
artifacts/
341-slope-capture.jsonl
# IconForge DAT extraction scratch (geometry + textures dumped from the
# installed client dats; regenerate with tools/MosswartArt, never commit).
tools/IconForge/work/

323
AGENTS.md
View file

@ -132,194 +132,8 @@ user-accepted, including exact response flags, independent examination
window, inscription transaction, complete creature/item/spell reports,
favorite-spell press/right-click behavior, modern scarab/prismatic formula,
DAT component icons, foreground stacking, and authored 310 x 400 extent.
Slice 4 equipped-child world picking passed its two-client Coldeve gate and
was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor
experience — browse, staged buying, selling, walk-to-use, the authored
panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE
(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed
two latent client-wide crashers (#348 cursor-handle exhaustion, #350
render-ledger overflow).** **Campaign P — physics retail-feel parity
(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31
— final user matrix accepted.** Every physics-scope gap from the
2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800
sentinel — ACE's >=800 is a misread; never re-import), the #265/#166
landing-momentum + bounce family
(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae
panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide
— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod
parse). See the plan doc for the retired-row ledger. **Campaign A — audio
retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is
CODE-COMPLETE 2026-08-08** with slices A1A6 landed and listening-gate
rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and
the formal plan-status flip. **Campaign CH — chat & interface-text retail
parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED
USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail
colors, the SpewBox with retail's two-plane glyph outlines, working side
channels, the 152-verb command registry, the CH6 window shell (floating
windows, all-corner resize, opacity), and verbatim /help. Carried tail:
#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1S3
polish items. **Campaign OP — the retail four-tab Options panel
(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE
2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat /
Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are
acdream's ONE in-client settings surface (design D1): F11/toolbar open the
authored tab host; `RuntimeCharacterOptionsState` + the 53-id
`CharacterOptionTable` own option storage; retail's wire split ships exactly
(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1`
PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`);
headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED);
OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the
`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/
combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix
batch (#372 both halves, #374, #375, #378#382, #385) is USER-PASSED; the
OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif
row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT
tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4,
OS-localized fallback, register AD-96; the `InitiateBinding` capture-
instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`,
live-verified no-crash). **STILL OWED: the full §OP3§OP6 script sections
and §OP8's visual re-check** — script
`docs/research/2026-08-11-campaign-op-test-script.md`, launch with
`ACDREAM_RETAIL_UI=1`. Tail:
#371, #373, AP-198/199/201/202/203. START at
`claude-memory/project_settings_options_digest.md`.
**Campaign FA — the retail social panel (Fellowship & Allegiance)
(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is
CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social
panel (Friends / Allegiance / Fellowship / Squelch, host slot
`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only —
Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel
recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire
(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR
tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and
the truncated XP-share table), two session-scoped Runtime owners
(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at
generation reset — D2 corrected), and the authored panels through
`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners.
**The fellowship two-session flow is PROVEN over the live wire** — FA6's
automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/
`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips
`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round →
narrow re-review; the reviews caught what tests can't (retail's 4th tree
rule, the D2 reset-lifetime inversion, the D6 server-side invite filter,
a seam-map entry that would have re-introduced a fixed bug). OWED: the
user's connected gates (§FA3-§FA6 of
`docs/research/2026-08-12-campaign-fa-test-script.md`, several
`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is
deferred/disabled because ACE returns NOTHING to the `0x001D` swear at
0.005 m (no confirmation, no tree update, no error; needs ACE-console
disambiguation — the swear CODE is done+reviewed, only its automated
two-session proof is unverified; register AD-87). Tail: #383 (installed-
DAT vs committed-fixture drift, found at FA3). START at
`claude-memory/project_fellowship_allegiance_campaign.md`.
**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all
USER-PASSED.** The social panel's connected gate rounds closed (border-only
move cursor, amber row selection, wrapped empty-state text, composed
confirmation sentences via the new `DatStringResolver.ResolveTemplate`
StringTable-interleave port, the refused-drop SpewBox notice via the
`InventoryTransactionState.RequestFailed` seam, live friends
Online/Offline through the authored row state machine + the new UiText
per-state string swap). Same block: powerbar mode captions
(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by
combat mode), release-edge airborne jump refusal (supersedes CH round-1's
press-edge report), and **SECURE TRADE SHIPPED + two-client user gate
PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`),
full `0x1F6``0x208` wire, `RuntimeTradeState` as the third sibling
J-owner, both retail open paths, staged-item trading marker
(`ClientObject.TradeState` now live), cancel text. START at
`claude-memory/project_secure_trade.md`; the deferred-Func lesson is
`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register:
AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired.
Filed: #393 (texture-detail options, post-M4).
**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia
launcher/installer/updater (Windows+Linux) + the retail character-
management screen, driven autonomously under a user-set goal: Fable
plans, Sonnet implements, Opus dual-lens reviews (architectural +
retail-faithful). Spec:
`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan +
ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at
`claude-memory/project_launcher_direction.md`. Key recon corrections
already binding: retail's select screen (`gmCharacterManagementUI`) has
NO 3D preview (chargen-only machinery); UI Studio no longer exists
(deleted at Campaign V — ignore stale memory/docs claims otherwise);
App `Program.cs` has no subcommand dispatch (the `--session-config` flag
is additive).
LA0 through LA11's automated scope are review-closed. The launcher composer is now
compiled into both host test suites, and Launcher.Core runs in the portable
Windows/Ubuntu CI closure. The self-contained Avalonia launcher,
transactional two-host plugin lifetime, shared login-command route,
Runtime-owned retail selection state, authored DAT character screen, and
crash-safe verified installer plus atomic cross-platform updater/self-updater
are integrated. Windows group-isolated Headless stop, isolated update fixtures,
strict status/redaction evidence, and the exact Windows/Ubuntu operator script
are landed; the integrated preflight passes 32/32 commands and 14,012 tests /
5 skips. Only the connected/visual/real-DAT user gate remains before shipment.
**Campaign CC — retail character creation (CLOSED USER-ACCEPTED
2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as
one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches
A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and
PASSED. **Milestone: the first live character ever created by acdream
against ACE landed mid-round.** The gate round's own harvest hardened
shared surfaces well beyond chargen: authored text margins (P0x23-26),
the authored Unselected/Selected state pair + per-state label color,
un-consumed Type-12 media children (frames/scrollbars client-wide),
single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the
dialog-always-on-top re-raise (the invisible-modal input blackhole), a
truthful client crash self-report + bounded stderr capture (#405-#407
fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create
button (retail's exact `UpdateButtons` roster<slots ghost gate)
`gmCharGenMainUI`'s six-page flow (Heritage / Profession / Skills /
Appearance with live 3D preview / Town / Summary with its own zoomed-out
viewport) → byte-exact 0xF656 with the 55-slot invariant → complete
0xF643 handling (roster append + retail log-straight-in; every rejection
dialog, incl. the corrected ground truth that retail shows NameDBDown
for Pending/Undef — the plan's original "retail swallows it" was
DISPROVEN at CC5's review) → the §LA1 `characterCreated`/`creationFailed`
launcher status cycle. `RandomizeCharacter` + sub-primitives are ported
(retail's ctor-time open-roll incl. the gender-flip quirk; humans-only
random heritage ids 1-4 — a real retail quirk). Plan + ledger:
`docs/plans/2026-08-15-character-creation-campaign.md`; connected gate
script: `docs/research/2026-08-16-campaign-cc-test-script.md` (launch:
launcher flow, or `ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`);
START at `claude-memory/project_character_creation_campaign_handoff.md`.
Register churn: AP-214/AP-225/TS-82/AD-101 retired; AP-211 updated;
AP-212 narrowed; AP-215AP-229 filed (AP-221 one-shot preview binding,
AP-222 spin-highlight no-op, AP-229 stacked-screens-vs-retail-teardown
are the ones a gate tester will meet). Known-flake set now also names
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
(full-solution parallel load only). Suites at `2176ba76`: full solution
14,426 / 4 skips, App 5257/3, Runtime 1735/0, Launcher.Core 324/0.
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
placement route now runs through the canonical residence + continuation-
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
(`6dc7ba51`), 6 drops (`1b484937`, zero production lines), 5 projectile
(`36255af0`), 7 child-cell propagation (`cd3129e9`), 3 portal
(`e0f96a55`), plus the `OnPosition` dual-tail collapse (`edc911b0`) that
retired the duplication behind three separate defects. Suite 11,027 →
**11,090 passed / 4 skipped / 0 failed**. Connected gates: routes 3, 6 and 7
user-passed 2026-08-05 with probe evidence; route 7's is THIN (one
`cause=propagate`) and 4b-3's `cause=cellless` case remains unrun with an
UNESTABLISHED trigger — route 7 invalidated its recorded recipe.
**C5 COMPLETE — the placement campaign is FULLY CLOSED (`addb5657`,
2026-08-07).** C5a deleted the legacy resolver outright and retired
AP-1/AP-145 (closing #318); C5b closed #275 and filed AP-147/AP-148; C5c's
closeout passed its 11,196-test automated gate and the owed connected-gate
batch USER-PASSED 2026-08-07. #280's portal-prefetch fix and its dual
review also landed (AP-149/150/151), and AP-22 retired 2026-08-06. Start
any new placement work at `claude-memory/project_placement_cutover_closed.md`
(probes deliberately NOT stripped; start at #331).
**Read `docs/research/2026-08-05-c4-closeout-handoff.md` before any
placement work.** Its seven process findings remain binding. The two that
cost the most that campaign: a contract asserting a mechanism that does not
exist caused three separate defects, and inferring a fact you can observe
made one fix strictly worse than the bug it replaced — it removed the
invariant failure while leaving the bug.
Resume at Slice 4 equipped-child world picking, then vendor browse and
authoritative transactions.
**Modern Runtime/performance status:** Slices AK of
`docs/plans/2026-07-24-modern-runtime-architecture.md` are complete. Slice L is
@ -752,10 +566,9 @@ The capped/RDP jump-presentation cadence alias is deferred as issue #235:
uncapped Release presentation is smooth, while physics, collision, and wire
truth remain correct.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #116 (Campaign P P2),
remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L,
and #225's lifestone/particle alpha visual gate. #153 closed 2026-07-30
(Campaign P P5 ledger evidence chain).
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L, and #225's
lifestone/particle alpha visual gate.
Start structural work at `memory/project_gamewindow_decomposition.md` and
`docs/architecture/code-structure.md`; start
@ -765,9 +578,6 @@ render/streaming work at `claude-memory/project_render_pipeline_digest.md`.
Documentation entry point: [`docs/README.md`](docs/README.md).
For canonical state, read in this order:
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
@ -1557,44 +1367,67 @@ 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. 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)`).
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)`.
### Diagnostic env vars
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 for every probe we have and how to turn one on, and it is
enforced by `LaunchOptionsDocumentationTests`: a flag without a documented
row fails the build, and so does a documented row whose read site was
deleted. **Any future probe that stays in the code gets its row there in
the same commit — no exceptions.**
The binding rules:
- **Every probe and dump is OFF by default.** Nothing that prints, records,
or costs performance may activate without its env var explicitly set
(`=1`). The only default-on flags are retail *behaviors* wearing an
A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`,
`ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES``=0`
disables); that set is frozen by `LaunchOptionsDocumentationTests`
never add a default-on diagnostic.
- **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.
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
animation debugging.
- `ACDREAM_STREAM_RADIUS=N` — tune landblock visible-window radius
(default 2 = 5×5).
- `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 (~100500 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_CAPTURE_RESOLVE=<path>` — 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=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — dump
resolved cell/GfxObj polygon tables as JSON when ids cache. Used
for harness fixture extraction.
### Outbound motion wire format (acdream → ACE)
@ -1645,8 +1478,8 @@ already-running ACE session via the handshake race.
## Reference repos: cross-check the relevant ones
The `references/` tree holds **five** vendored projects (ACE, ACViewer,
WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in
The `references/` tree holds **six** vendored projects (ACE, ACViewer,
WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
some areas and disagree in others. Before committing to an approach,
**cross-reference at least two of them** for the domain you're working
in — the per-domain hierarchy in the next section tells you which to
@ -1655,7 +1488,7 @@ the relevant references is almost always the truth. The user has
repeatedly had to remind me about this when I narrowly searched one ref
and missed obvious answers in another.
The five references:
The six references:
- **`references/ACE/`** — ACEmulator server. Authority on the wire
protocol (packet framing, ISAAC, game message opcodes, serialization
@ -1705,15 +1538,15 @@ The five references:
the message-builder layer. ACE shows what the server expects;
holtburger shows what a real client actually sends.
**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo
and the sixth entry in this list; it is no longer vendored under
`references/` and must not be re-cloned. Everything we took from it is
already written down and still stands: the terrain split formula
`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a
client need not compute terrain Z itself. The historical analysis lives in
`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed
is in `docs/research/retail-ui/`. Cite those, not the repo.
- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference,
fixed-function OpenGL, but has the **real AC terrain split formula**
(`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`,
`0x519B8F25`) which differs from WorldBuilder's physics-path formula.
Also has the complete `0xF61C` movement packet format with flag bits
and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does
NOT do client-side terrain Z — it sends movement keys to the server
and uses the server's authoritative Z. See
`docs/research/2026-04-12-movement-deep-dive.md` for the full analysis.
### Reference hierarchy by domain
@ -1738,9 +1571,9 @@ decompiled client code and would have fixed it in minutes.
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. |
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. |
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. |
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |
| **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. |
| **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. |
| **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. |

View file

@ -11,31 +11,20 @@
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj" />
<Project Path="samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj" />
<Project Path="samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/A8CellAudit/A8CellAudit.csproj" />
<Project Path="tools/AnimHookScan/AnimHookScan.csproj" />
<Project Path="tools/dump-keymap/dump-keymap.csproj" />
<Project Path="tools/LayoutDump/LayoutDump.csproj" />
<Project Path="tools/MosswartArt/MosswartArt.csproj" />
<Project Path="tools/PesChainAudit/PesChainAudit.csproj" />
<Project Path="tools/ProjectileVfxAudit/ProjectileVfxAudit.csproj" />
<Project Path="tools/RainMeshProbe/RainMeshProbe.csproj" />
<Project Path="tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj" />
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
<Project Path="tools/SetupInspect/SetupInspect.csproj" />
<Project Path="tools/ShaderCompiler/ShaderCompiler.csproj" />
<Project Path="tools/SkyObjectInspect/SkyObjectInspect.csproj" />
<Project Path="tools/SpellDump/SpellDump.csproj" />
<Project Path="tools/StarsProbe/StarsProbe.csproj" />
<Project Path="tools/TextureDump/TextureDump.csproj" />
<Project Path="tools/WeatherEnumerator/WeatherEnumerator.csproj" />
@ -56,11 +45,7 @@
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Plugins.MossTank.Tests/AcDream.Plugins.MossTank.Tests.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple/AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple.csproj" />
<Project Path="tests/AcDream.RenderPackValidator.Tests/AcDream.RenderPackValidator.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder>

135
CLAUDE.md
View file

@ -765,7 +765,6 @@ Documentation entry point: [`docs/README.md`](docs/README.md).
For canonical state, read in this order:
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
@ -1555,44 +1554,108 @@ 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. 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)`).
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)`.
### Diagnostic env vars
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 for every probe we have and how to turn one on, and it is
enforced by `LaunchOptionsDocumentationTests`: a flag without a documented
row fails the build, and so does a documented row whose read site was
deleted. **Any future probe that stays in the code gets its row there in
the same commit — no exceptions.**
The binding rules:
- **Every probe and dump is OFF by default.** Nothing that prints, records,
or costs performance may activate without its env var explicitly set
(`=1`). The only default-on flags are retail *behaviors* wearing an
A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`,
`ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES``=0`
disables); that set is frozen by `LaunchOptionsDocumentationTests`
never add a default-on diagnostic.
- **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.
- `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 (~100500 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=<metres>` sets the window (default 30).
TEMPORARY, with the #337 probe family.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — 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=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — dump
resolved cell/GfxObj polygon tables as JSON when ids cache. Used
for harness fixture extraction.
### Outbound motion wire format (acdream → ACE)

View file

@ -97,8 +97,8 @@ missing.
- A machine-local `acdream.pak` built from those DATs
- A running ACE server for connected play; the examples use
`127.0.0.1:9000`
- For the graphical client, a driver exposing the mandatory Vulkan
capabilities validated at startup
- For the graphical client, a driver exposing the mandatory modern OpenGL
capabilities
The project does not distribute Microsoft/Turbine DAT files or derived
prepared packages.
@ -111,9 +111,10 @@ dotnet build AcDream.slnx -c Release
dotnet test AcDream.slnx -c Release --no-build
```
The current CI-filtered Windows baseline is a successful Release build with
**16,151 passing tests and zero failures**; opt-in live, installed-DAT,
prepared-package, manual, timing, and platform-specific lanes run separately.
The current baseline is a successful Release build with **8,826 passing tests
and 5 intentional skips**. The build currently reports 17 test-project
warnings tracked by [`#228`](docs/ISSUES.md#228--clean-release-build-emits-17-test-project-warnings);
production compilation has zero errors.
## Prepare content
@ -126,9 +127,8 @@ dotnet run --project src\AcDream.Bake\AcDream.Bake.csproj -c Release -- `
--out "C:\Games\Asheron's Call\acdream.pak"
```
A complete format-2 package from the standard installed DAT set is about
570 MiB (the former format-1 package was about 30 GB). It is machine-local and
must not be committed. `ACDREAM_PAK_PATH` overrides the default
A complete package is approximately 30 GB. It is machine-local and must not be
committed. `ACDREAM_PAK_PATH` overrides the default
`<DAT directory>\acdream.pak`.
## Run the graphical client
@ -141,6 +141,7 @@ $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 -c Release
```
@ -207,7 +208,7 @@ built-in policies are `idle`, `lifecycle-smoke`, `observer-movement`, and
| `ACDREAM_LIVE=1` | Enable connected mode |
| `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint |
| `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials |
| `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default |
| `ACDREAM_RETAIL_UI=1` | Enable the retained retail gameplay UI |
| `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools |
| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization |
| `ACDREAM_UNCAPPED_RENDER=1` | Disable normal frame pacing for diagnostics |

View file

@ -1,93 +0,0 @@
# acdream application icons
Two marks, one family.
| Mark | Files | Used by |
|---|---|---|
| **Client** — the mosswart head | `acdream-client-*.png`, `acdream-client.ico` | `AcDream.App` (PE icon + runtime window icon) |
| **Launcher** — the ring and crescent | `acdream-launcher-*.png`, `acdream-launcher.ico` | `AcDream.Launcher` (PE icon + Avalonia `Window.Icon`) |
Each ships PNGs at 16/24/32/48/64/128/256/512/1024 plus a multi-size `.ico`
carrying 16 through 256.
## Where the art comes from
**The client mark is the retail mosswart**, not a drawing of one. It is the
actual creature head — `Setup 0x02000B4F` part 14, skin atlas `0x05001E11`,
`ClothingBase 0x10000344` — pulled from `client_portal.dat`, smoothed, lit and
graded. Palette values throughout both marks are sampled from that texture:
| | |
|---|---|
| `#ACB820` | chartreuse upper skin |
| `#A09800` | mustard belly — the "foul yellow" the lore names |
| `#485010` | deep olive shadow |
| `#F2ECD2` | tusk bone |
| `#AC7438` | ear membrane / hide |
**The launcher mark is inspired by the Asheron's Call sigil** — a forged ring
enclosing a hooked crescent — rebuilt from measurements of the retail wordmark
and the `acclient.exe` icon resource. It is an original construction in the
same visual language, not a copy of the logo. Its warm field matches the retail
client icon's dark-to-gold interior.
> **Note on rights.** "Asheron's Call" and its logo are trademarks of their
> owners, and the client mark is rendered from copyrighted game art. Unlike DAT
> content — which stays on the user's own disk — these icons are compiled into
> the shipped binaries. If acdream is ever distributed broadly, both marks
> should be reviewed, and the client mark is the one most likely to want an
> original redraw using these renders as reference.
## Regenerating
The launcher mark is fully procedural and rebuilds anywhere:
```bash
py tools/IconForge/forge.py launcher
```
That is byte-for-byte deterministic — it reproduces the committed PNGs exactly,
so an accidental edit is visible as a diff.
The client mark renders real game geometry, so it needs the installed DATs.
One command extracts both halves — the posed geometry and the surfaces it
references — into `tools/IconForge/work/`:
```bash
dotnet run --project tools/MosswartArt -- 0x02000B4F 0x10000344 tools/IconForge/work/mosswart_mesh.json 0x09000009
```
The trailing MotionTable id is required. Creatures do not define an upright pose
in `Setup.PlacementFrames`; without it every part stacks on the origin.
Then:
```bash
py tools/IconForge/forge.py client
```
This is deterministic too — given the same DATs it reproduces the committed
PNGs byte-for-byte.
Requires Python with `numpy`, `pillow` and `scipy`.
## How they are wired in
Neither icon is loaded from disk at runtime.
- **PE icon**`<ApplicationIcon>` in each `.csproj`, pointing at the `.ico`
here. This is what Explorer and the taskbar shortcut show.
- **Client window icon**`AcDream.App.Rendering.WindowIconLoader` hands GLFW
four sizes **from the `Load` callback**. That timing is load-bearing: Silk's
`Window.Create` only builds the managed object, and `IWindow.Initialize` is
what creates the native window, so applying an icon any earlier throws
"Window should be initialized". The failure is quiet and misleading — GLFW
falls back to the stock Windows application icon rather than the
executable's, so Explorer shows the mark and the running window does not.
The PNGs are *embedded resources* linked from this directory, so there is one
source of truth for the art and no missing-file case at runtime.
`WindowIconLoaderTests` guards both the resource names, which are otherwise
coupled to `LogicalName` in the csproj by string only, and the call-site
ordering.
- **Launcher window icon**`AvaloniaResource` linked from here, referenced as
`avares://acdream-launcher/Assets/acdream-launcher.png`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because it is too large Load diff

View file

@ -82,16 +82,6 @@ document in the same change; do not leave both claims standing.
- [`superpowers/specs/`](superpowers/specs/) and
[`superpowers/plans/`](superpowers/plans/) are per-slice design and execution
records. Completed plans remain historical.
- [`ci-and-releases.md`](ci-and-releases.md) is the SSOT for the Gitea CI
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

View file

@ -137,68 +137,6 @@ loads none). The headless adapter projects entity snapshots on demand from the
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
exact Runtime selection owner; it does not mirror gameplay state.
Graphical plugin panels are first-class retained windows. A plugin calls
`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped
host authenticates the owner from the loaded manifest, and App derives the
stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns
window geometry, z-order, persisted visibility, minimize/restore chrome, and
the shared right-edge plugin shelf. Minimizing only hides the presentation:
the plugin session, event subscriptions, automation policy, and binding object
remain live. Legacy `AddMarkupPanel` registrations are enriched into the same
first-class path, so API-v1 plugins keep working without a second lifecycle.
The markup vocabulary includes nested groups plus retained tab, toggle,
slider, editable-field, and retail-menu controls. Fields bind live
`Action<string>` change/submit callbacks and menus bind an
`IEnumerable<string>` plus selection callback, so plugin-owned profile/rule
editors stay behind the BCL contract instead of importing App widgets. These
are presentation bindings only and never become parallel gameplay owners.
Durable plugin data uses the BCL-only `IPluginHost.Storage` contract. Core's
manifest-authenticated scoped host prefixes every logical key with the loaded
plugin id; graphical composition writes atomically beneath the per-user config
root (`plugins/{pluginId}/...`). Plugins receive neither another plugin's
namespace nor a machine-specific path. Hosts without durable storage expose
`NoOpPluginStorage` and report the capability unavailable.
The additive `List(prefix)` operation enumerates only keys inside that same
authenticated namespace, allowing plugins to discover explicit import/export
files without receiving a filesystem path or crossing plugin ownership.
`IPluginHost.Automation` is the additive gameplay-automation projection. Its
character, spell, magic, chat, combat, equipment, item, loot, fellowship,
enchantment-observation, and navigation
groups contain BCL-only immutable snapshots plus attempt-style commands; the
graphical implementation borrows the exact `GameRuntime`
character/action/entity/object/vendor/fellowship owners. Item projections also
carry Virindi's stable ObjectClass plus ordered ObjDesc subpalette samples;
the graphical host resolves each representative RGB directly from portal DAT
using VTank's sample-index formula. MossTank owns all
macro policy (buff planning, target rules, selection scoring, corpse policy,
loot-rule ordering and action timing). In particular, `ICombatAutomation`
does not create a plugin combat model: each hostile capture is a detached
point-in-time projection of `RuntimeHostileTargetQuery`, and physical commands
enter the canonical `RuntimeCombatModeState` / `RuntimeCombatAttackState`
press-charge-release state machine. Item and loot commands similarly enter
App's one `ItemInteractionController`: appraisal, use/apply, pickup,
move/split/merge/drop/give, retail 0x027D salvage, and current-vendor sale
reuse the same readiness checks, reservations, wire sends, and authoritative
completion/object-table signals as retained retail UI. Plugins never hold an
optimistic inventory or vendor shadow. Navigation similarly projects live and
server-accepted position, portal/object state, and semantic movement levels;
the App host applies those levels through Runtime's one command interpreter.
Route sequencing, steering cones, follow breadcrumbs, checkpoint policy,
door/lockpick decisions and portal retry behavior remain plugin-owned. The
shared enchantment-observation group is deliberately a confirmed-cast timer
ledger rather than another authoritative spellbook: the host records successful
local duration casts and cooperating plugins can report their own confirmed
casts, matching VTank's `LogSpellCast` contract. It resets at session detach;
dispel/debuff policy remains plugin-owned. The
inert default remains
`NoOpAutomationSurface`, preserving one plugin code path on hosts without a
live gameplay session.
`ICharacterInfo.Name` projects the canonical local `ClientObject.Name` (empty
when unavailable) solely for per-character plugin profile scoping; it does not
introduce a second identity owner.
Core `SelectionState` is the sole selected-object owner for world,
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
@ -394,19 +332,7 @@ src/
Installation/ -> portable four-DAT validation, Windows retail
path discovery, versioned JSONL bake-process
orchestration, and atomic SHA/size/tool-version
install-record verification and recovery;
startup discovery begins only after the desktop
window opens, and exceptional whole-pak hashing
reports its long-read status in that window;
content recipes resolve through one compiled
None/Overlay/FullRebuild/Verify migration ledger;
bounded changes build one cumulative filtered
overlay and publish `pak/content.current.json`,
while full rebuilds bake beside the live base and
swap only after candidate verification; a tiny
`pak/content.client-pending` gate survives a
crash/restart until the active client is
confirmed compatible; one
install-record verification and recovery; one
OS-handle lease serializes recovery/install per
DataDirectory; a second OS-held publication
lock plus durable per-transaction nonce makes
@ -436,9 +362,7 @@ src/
is never persisted, and permits HTTP only for a
loopback fixture; production remains pinned HTTPS
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard, explicit
world-data work confirmation (kind, reason,
free-space guidance, progress/cancellation), and
including the first-run DAT/bake wizard and
nonfatal startup/manual update state, actions,
progress, cancellation, rollback, and errors
-> references Launcher.Core only (Platform transitively); it never owns
@ -448,28 +372,6 @@ src/
-> Linux launcher/probe/headless flows remain portable; graphical-client
actions are explicitly disabled until Modern Runtime Slice L resumes
Prepared-content launch contract
-> `install.json` remains the strict backward-compatible base-pak authority
-> format 2 / recipe 6 is the current full-package contract: globally
shared texture payloads, independent adaptive blob compression, and
source-native BC1/2/3 for surfaces that require no pixel-local edit
-> format changes always use an explicit confirmed full rebuild; the
launcher preflights 2 GiB free space, shows long-work progress, validates
the candidate beside the active package, and promotes atomically
-> the prepared texture LRU is bounded to 64 MiB / 1,024 entries; GPU atlas
ownership remains in App and the whole-file mmap is virtual, not a
committed-copy cache
-> optional `pak/content.current.json` binds one cumulative overlay to the
base SHA; there is never an unbounded overlay chain
-> `pak/content.client-pending` prevents newly migrated content from
becoming launchable before the matching client check/install succeeds,
including across launcher restart
-> launcher session config carries base + optional overlay paths and both
recipe identities only for layered launches
-> App and Headless construct one `LayeredPreparedAssetSource`; overlay
Missing falls through to base, while overlay Corrupt is authoritative
for both render and collision reads
AcDream.Headless/ Linux/Windows no-window production host
Program.cs -> CLI entry only
Configuration/ -> strict versioned process/session config
@ -491,10 +393,6 @@ src/
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
IPluginStorage.cs -> manifest-scoped durable text profiles
Automation.cs -> character/spell/magic/chat automation groups
CombatAutomation.cs -> hostile snapshots + retail combat attempts
EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger
AcDream.App/ Layer 1 + Layer 4 wiring
Platform/

File diff suppressed because one or more lines are too long

View file

@ -22,7 +22,7 @@ particles), and uses the same Silk.NET + .NET stack we target.
our tree (see CLAUDE.md for the full breakdown):
- `src/AcDream.Core/Rendering/Wb/` — pure helpers (no GL): `TerrainUtils`,
`TerrainEntry`, `RegionInfo`, `SceneryHelpers`, `TextureHelpers`.
- `src/AcDream.App/Rendering/Wb/`Vulkan/RHI infrastructure + mesh pipeline:
- `src/AcDream.App/Rendering/Wb/`GL infrastructure + mesh pipeline:
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes.
@ -42,7 +42,7 @@ non-render content plus explicit bake/equivalence tooling.
**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.**
The GL-free portion of the former `ObjectMeshManager` — dat read → polygon
walk → vertex/index build → palette/conditional BCn texture decode →
walk → vertex/index build → inline BCn/palette texture decode →
`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/`
assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact
same extraction code offline without an OpenGL context. This was a
@ -84,7 +84,7 @@ behavior change, no divergence-register row.
survive, as they always did). The MP1b bake tool passes its own
collector.
- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (the
staged-queue/worker-pool/Dispose-quiesce lifecycle and all Vulkan/RHI upload;
staged-queue/worker-pool/Dispose-quiesce lifecycle and all GL upload;
production workers now consume `IPreparedAssetSource`),
`ObjectRenderData`/`ObjectRenderBatch`
(hold a GL `TextureAtlasManager` field), `TextureAtlasManager`,
@ -120,8 +120,9 @@ EnvCell, Surface, palette, and texture graphs during portals. The validated
machine-local `acdream.pak` is opened through Content's
`IPreparedAssetSource`; typed GfxObj and EnvCell requests deserialize immutable
`ObjectMeshData` while retaining the existing App worker, staging, render-thread
upload, cache, ownership, and shutdown contracts. The prepared render payload
persists exact batch translucency so App does not reconstruct a
upload, cache, ownership, and shutdown contracts. The original
format-1/bake-tool-3 render payload persists exact batch translucency so App
does not reconstruct a
`GfxObjMesh` for metadata. Setup activation uses the package TOC as an explicit
type-presence index before reading valid Setup records through the bounded DAT
cache. `DatPreparedAssetSource` and `MeshExtractor` remain explicit
@ -131,39 +132,8 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes
installed-DAT gates are recorded in
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
**Launcher cumulative-overlay extension (2026-08-25).** Production still has
no live-DAT fallback and consumes the same prepared-payload contracts. For a
bounded recipe migration, App and Headless may receive one complete base pak
plus one cumulative filtered pak through `LayeredPreparedAssetSource`. The
overlay is probed first: Missing falls through to the base, while a present but
corrupt render or collision payload remains authoritative corruption. Both
mapped owners share one composite lifetime and there is never an overlay
chain. The launcher binds the overlay to the base digest in the optional
`pak/content.current.json` sidecar; format/global extraction migrations retain
the explicit full-rebuild path. A tiny `pak/content.client-pending` marker
keeps either result non-launchable until the matching client is confirmed,
including across a crash/restart. Design and gates:
`docs/plans/2026-08-25-launcher-content-stabilization.md`.
**PAK v2 resource closeout (2026-08-27).** Format 2 / bake recipe 6 retains
the fixed header and sorted random-access TOC while moving texture arrays into
one globally shared type-8 payload partition and independently applying
adaptive Brotli compression with raw fallback. Unedited DXT1/3/5 surfaces
retain exact DAT BC blocks through Vulkan upload; clip maps and authored
translucency continue through the RGBA edit path. The reader bounds decoded
blobs and retains shared texture arrays in a 64 MiB / 1,024-entry LRU. The
complete installed package is 597,229,424 bytes versus 29,908,271,024 bytes
for format 1; four- and nine-worker bakes have identical SHA-256. The
authoritative connected before/after route reduced heavy-route working set by
48.0%, private bytes by 26.1%, and prepared-mesh GPU bytes by 25.9% with equal
or faster matching reveal/frame percentiles. Format changes remain mandatory
launcher-confirmed full rebuilds with beside-active validation and atomic
promotion; overlays never cross a format boundary. Design and evidence:
`docs/plans/2026-08-27-pak-v2-resource-campaign.md`.
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 13; bake-tool 4
appended typed GfxObj,
**Slice I3 prepared collision extension (2026-07-25).** The package remains
format 1 and retains mesh type values 13; bake-tool 4 appends typed GfxObj,
Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the
immutable flat records and deterministic raw-DAT flattener. Content owns the
strict little-endian codec and `IPreparedCollisionSource`.

View file

@ -1,150 +0,0 @@
# Continuous integration and alpha releases (Gitea)
Single source of truth for how acdream builds, gates, and ships alpha builds.
Landed 2026-08-19. Companion to [`release-gate.md`](release-gate.md), which
owns the *local* bounded gate.
## What happens on a push to main
```
git push origin main
├─ windows-gate (RARE-win) build + full lane-filtered suite
├─ linux-portable (eriktestLinux) portable closure, Linux lanes
└─ release (needs BOTH green) publish a Gitea Release
+ republish the `latest` pointer
```
Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Docs-only
pushes (docs/, the memory trees, markdown) skip the pipeline entirely — no
test can fail on them and a run costs ~7 minutes plus a 121 MB release. A red gate
cannot publish: `release` uses `needs:`, not a `workflow_run` trigger, whose
Forgejo support is unreliable.
## Why Gitea and not GitHub
GitHub Actions is **billing-blocked** on this account ("recent account payments
have failed"), and the repo is private, so hosted runners consume paid minutes.
Forgejo ships **no hosted runners at all**, so Actions there requires
self-hosted ones — which are free on both platforms. The same two machines can
serve GitHub later by registering a second agent; only the workflow's
`runs-on` labels change.
## The runners
| | Windows | Linux |
|---|---|---|
| Host | `RARE` (10.6.0.3) | `eriktestLinux` (10.0.0.202) |
| Agent | `act_runner` 0.2.13 | `forgejo-runner` 13.0.0 |
| Persistence | Scheduled task `ForgejoRunner`, at logon of `acbot` | systemd `forgejo-runner`, `Restart=always` |
| Labels | `windows`, `windows-latest`, `windows-x64` | `ubuntu-latest`, `ubuntu`, `linux`, `ubuntu-slim` |
| Execution | host mode (`:host`) — no Docker on either box | host mode |
Both **poll outbound** over HTTPS. Gitea never connects to them, so no inbound
ports, no port forwarding, and no static IP; they work behind NAT. The runner
does not have to live next to the Gitea container (which runs on `bluesnake`,
a host we have no shell on).
`forgejo-runner` publishes **no Windows binary in any release**, which is why
Windows uses Gitea's `act_runner`. Forgejo speaks the same Actions protocol.
### Prerequisites on a runner
- **.NET SDK in the `global.json` band** — currently `10.0.3xx`. `10.0.400` is a
different feature band and `rollForward: latestPatch` rejects it.
- **Node.js**`actions/checkout` and `actions/upload-artifact` are JavaScript
actions. Docker images normally supply Node; in host mode the machine must.
- **Git**, and outbound HTTPS to `git.snakedesert.se`.
- **PowerShell 7** on Windows (`pwsh`); `tools/*.ps1` require it.
## Releases
Everything about distribution lives under **Releases** — nothing in git. A build
is ~120 MB, so payloads are release attachments; and the pointer the launcher
polls is itself a release asset, so there is no payload branch, no bot commit on
`main`, and no push that could retrigger the pipeline.
```
Release 0.1.0-build.<yyyyMMddHHmm> <- the actual build
client-win-x64.zip AcDream.App.exe + acdream-headless.exe
launcher-win-x64.zip acdream-launcher.exe + acdream-bake.exe
manifest.json
Release latest <- pointer, replaced every publish
manifest.json names the version above and its asset URLs
```
The launcher polls the pointer at a URL that never changes
(`ReleaseManifestClient.ProductionManifestUri`):
```
https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json
```
A pointer is needed because **Forgejo has no `/releases/latest/download/`
route** (verified: 404) — unlike GitHub, there is no built-in stable URL for
"the newest release". Publishing it recreates the `latest` tag each time, which
means deleting the old release *and* its tag; the tag outlives its release and
would otherwise block recreation.
The newest **5** versioned releases are kept and older ones are pruned with
their tags. Each build is ~121 MB of attachments, so retaining every one grew
the server by that much per push — 5 builds had already reached 606 MB. Five is
enough to grab a previous build or bisect a regression while staying bounded.
The `latest` pointer is never pruned; it is the feed, not a build.
`tools/publish-bin.ps1 -BaseUrl <release asset base>` builds the payloads; CI
passes the tag's asset base. Running it locally is for inspection only —
publishing is CI's job.
### Verifying a release
```powershell
dotnet test tests/AcDream.Launcher.Core.Tests --filter Lane=Live
```
`LiveGiteaReleaseInstallTests` installs the advertised client from the real feed
through the production updater — real SHA-256/size verification, extraction, and
atomic activation — then asserts both hosts resolve out of the activated
directory and `current.json` names the installed version.
## Landmines
Each of these cost a red pipeline; none was a config typo. Two rows record a
fix that was tried and **disproved** — read those before repeating it.
| Symptom | Cause |
|---|---|
| `Cannot find: node in PATH` | JS actions need Node on the host in `:host` mode |
| `actions/setup-dotnet` never resolves | `data.forgejo.org` does not mirror it (404). `checkout` and `upload-artifact` **are** mirrored. Self-hosted runners carry the SDK anyway |
| Job "failed" while dotnet processes still run | `run-release-gate.ps1` redirects children to log files, so the step goes silent; Forgejo fails a non-reporting task as a zombie. CI runs `dotnet test` directly so output streams |
| ~40 tests fail on formatted numbers | Runner's `HKCU` locale was `en-SE` (comma decimal): expected `"update:0.25"`, got `"update:0,25"`. `Set-Culture` does **not** reach a scheduled task without a loaded profile — set the registry directly |
| `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` as the locale fix | Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead |
| `FileNotFoundException: client_cell_1.dat` | DAT-dependent tests missing `[Trait("Lane", "InstalledDat")]`. Build machines have no DATs |
| Timing-sensitive test fails only under load | It belongs in `Lane=Timing` (see [`release-gate.md`](release-gate.md)). Do **not** chase these individually: four separate fixes each surfaced a different member of the same family, and serializing `Core.Net` to fix Linux regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s |
| Avalonia "calling thread cannot access this object" in cleanup | `MainWindowViewTests` needs a real desktop session and is `Lane=Manual`. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under `act_runner` and on Linux. Serializing the assembly does **not** fix it (tried via `xunit.runner.json` and a compiled-in `CollectionBehavior` attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being **constructed** during teardown — it is the headless session lifecycle, not parallelism |
## Do not leave load on a runner
A stress/diagnostic run left going on a runner competes with CI for the same
machine and makes every job slower and more likely to trip a load-sensitive
test — the exact failures you would then be trying to diagnose. Kill background
work before trusting a timing result:
```powershell
Get-Process dotnet -ErrorAction SilentlyContinue | Stop-Process -Force # Windows
pkill -9 dotnet # Linux
```
Leave `act_runner` / `forgejo-runner` itself alone; killing those unregisters
nothing but stops the machine picking up jobs until it restarts.
## Culture note
The `en-SE` discovery is worth remembering beyond CI: config files, numeric
parsing, and the wire are all culture-safe (`System.Text.Json` is invariant by
spec, every `float/double.TryParse` passes `CultureInfo.InvariantCulture`, and
the protocol is binary). Only **diagnostic strings** format with the current
culture, so a European player sees `local=(8,00; 191,00)` in an F3 dump. The
client installs and runs correctly in both the US and Europe.

View file

@ -1,384 +0,0 @@
# 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
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
and measurement flag in this document is inert until its variable is
explicitly set — an unset environment runs zero diagnostics. Exactly
five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`,
`ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
off-switch (`=0` disables the behavior for a comparison run), while
`ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
the same explicit diagnostic opt-out. That five-flag set is frozen by
`LaunchOptionsDocumentationTests` — a new
default-on flag fails the build.
- `=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"
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` | `=<path>` | Points at a real retail AC install dir; loads `<dir>/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). 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>` (`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` | `=<path>` | 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` | `=<path>` | 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` | `=<path>` | 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` | `=<path>` | 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` | `=<int>` | 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` | `=<int>` | 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` | `=<int>` (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` | `=<int>` | 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` | `=<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 → `<datDir>/acdream.pak` | `RuntimeOptions.PreparedAssetPath``ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` |
| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags``AppAutomationSurface` / `LocalPluginPeerRegistry` |
| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=<int 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` | `=<int>` (`>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` | `=<int 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` | `=<int 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` | `=<int 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` | `=<int 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` | `=<int 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` | `=<int>` (`>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` | `=<int 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` | `=<int>` (`>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` | `=<int>` (`>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` | `=<int 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` | `=<int>` (`>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` | `=<int 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` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi``LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) |
| `ACDREAM_TEST_HOST` | `=<host>` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) |
| `ACDREAM_TEST_PASS` | `=<string>` | 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` | `=<int>` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) |
| `ACDREAM_TEST_USER` | `=<string>` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) |
| `ACDREAM_VULKAN_DEVICE` | `=<int>` (decimal index) or `=<substring>` (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 |
|---|---|---|
| `<dat-directory>` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. |
| `--session-config <path>` | 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 <path>` | The versioned headless session-configuration document. Required. | — |
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | 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` `<absolute path>` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. |
| `--update-manifest-uri <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 <frames.csv> <checkpoints.jsonl> <markers.log> <out.json>`,
`compare-screenshots <expected.png> <actual.png> <out.json> [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`,
`probe <in.png> <x0> <y0> <x1> <y1>`.
- **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 <in.png> <x> <y> <w> <h> <zoom> <out.png>`.
## Measurement and profiling
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_CAPTURE_RESOLVE` | `=<path>` | 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` | `=<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 | `<CurrentDirectory>/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` |
| `ACDREAM_COLLISION_SHADOW_EVERY` | `=<positive int>` | 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` | `=<int>` | 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` | `=<path>` | 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<FrameHistoryRecord>` 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` | `=<float>`, 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` | `=<float>`, 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` | `=<float>`, 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` | `=<int>=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` | `=<float>` (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 monotonic real elapsed 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` → monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>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` | `=<int 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` | `=<float 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` | `=<int>` (`>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` | `=<int>` (`>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` | `=<int 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` | `=<double 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` | `=<double 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` | `=<float>`, 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` | `=<path>` | 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` | `=<int 0-100>` (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` | `=<int>` (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&lt;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>` | 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` | `=<feature-name>` (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. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe``GameWindow.cs:828``VulkanBringUpHost` |
| `ACDREAM_VULKAN_PROBE_FRAMES` | `=<int>` (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` |
| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth``GameWindow.cs``MovementTruthDiagnosticController` |
## Permanent diagnostics
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=<path>` (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), 1 Hz progress, and one `SUMMARY` line at viewport reveal; paired low-frequency `[reveal-resource]` snapshots report mesh staging/uploads/arena state, prepared-asset activity, composite warmup/uploads, managed memory, and tracked GPU residency | print-only; the probe object and render-resource sampler are not constructed when unset. When enabled, canonical resource owners are sampled only at begin, readiness edges, 1 Hz progress, and summary—not every frame. Use with `ACDREAM_FRAME_PROF=1` / `ACDREAM_FRAME_HISTORY` for per-frame CPU/GPU/alloc timing. | unset (off) | `StreamingDiagnostics.ProbeRevealTiming`, `RevealTimingProbe`, `RuntimeRenderFrameResourceDiagnosticsSource`, `PublicationTimingProbe` |
| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` |
| `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]`) |
| `ACDREAM_DUMP_CELLS` | `=<comma list of hex cell ids>` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | 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 | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) |
| `ACDREAM_DUMP_CELLS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` |
| `ACDREAM_DUMP_GFXOBJS` | `=<comma list of hex GfxObj ids>` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage), pair of DUMP_CELLS. | file I/O once per matching id (no-op on repeat) | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) |
| `ACDREAM_DUMP_GFXOBJS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` |
| `ACDREAM_DUMP_SKY` | `=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. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | 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. | off/unset | `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` | `=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` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` |
| `ACDREAM_HIDE_PART` | `=<int>` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex``LivePresentationComposition.cs:608``LiveEntityAnimationPresenter.cs:21,38,243` |
| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` |
## 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 existed, citing 21
> distinct issues with 14 already closed. [#435](ISSUES.md) part 1 stripped
> the 17 rows whose investigation had ended without the strip — see the
> Retired section below for their removal record — leaving 47. Part 2
> traced each of the (then-)14 unattributed rows to its introducing commit
> and stripped the 7 that belonged to closed investigations
> (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37,
> `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8,
> `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor
> campaign, `ACDREAM_DUMP_VITALS`/#5). An eighth,
> `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day:
> it turned out to be automation apparatus, not a probe — the canonical
> nine-stop soak hard-gates on its output (see its row under Automation;
> #437 is the record). The rest of the attributed rows were reclassified
> into Permanent diagnostics as standing tools rather than investigation
> probes, leaving **31 temporary probes, every one attributed to an owning
> issue or campaign**. Each 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_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_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_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_LIGHT_DEBUG` | #176 | `=<int>` (`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_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_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_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_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_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_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | 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_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_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` |
## 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` | `=<int>` (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 -->
## 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` |
| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none |
| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_LIGHT` | #133 A7 dungeon-lighting `[light]`/`[light-detail]` trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_OUTSTAGE` | #131 outside-stage dynamics routing trace (also the `ACDREAM_DUMP_ENTITY` `[outstage-own]` watchlist consumer). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_PHANTOM` | #113 phantom-shell/phantom-objs draw-mechanism trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_REACH` | #334 broadphase candidate-disposition trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_REMOTE_LANDING` | Bug A / issue #32 remote ground-contact landing trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_SHELL` | #78 cell-shell opaque-pass render trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_STEP_HEIGHTS` | Issue #338 step-up/step-down height provenance trace (including its unconditional once-per-process `AnnounceStepHeightProbeOnce` self-report). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_STICKY` | R5-V3 issue #171 sticky-melee lifecycle/steer trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_SUPPORT` | Issue #337 `[support]`/`[geom]` collision-vs-visual classifier trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_TEXFLUSH` | #105 white-indoor-textures staged-upload trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_VIEWER` | #119-residual viewer/flood capture (tower-ascent replay). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none |
| `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none |
| `ACDREAM_A8_DUMP_PV` | Phase A8.F portal-frame visual-gate triage dump (camera-cell portal census + EXIT-PROJ/EXIT-CLIP/EXIT trace in `PortalVisibilityBuilder.Build`). Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_CLOTHING` | Issue #37 humanoid-coat clothing/part-swap trace. #37 closed 2026-05-11; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_EDGE_SLIDE` | Issue #32 L.2c edge-slide/cliff-slide branch trace (five `edge-slide:` lines). #32 closed 2026-08-07; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_LIVE_SPAWNS` | Phase A8 indoor-visibility batch live-spawn/DROP trace. Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_STEPUP` | L.2.3d/e/f step-up `stepup: enter/SUCCESS/FAILED` trace. Investigation closed; stripped 2026-08-24 (#435 part 2) — its content is still covered by the separate `[transit-fail-stepup]` line under `ACDREAM_DUMP_TRANSIT_FAIL`. | `ACDREAM_DUMP_TRANSIT_FAIL` |
| `ACDREAM_DUMP_VENDOR` | `[vendor-diag]` trace (~25 call sites) for two vendor-approach/split-stack regressions. Vendor campaign closed 2026-08-08; stripped 2026-08-24 (#435 part 2) along with its owner class `VendorDiagnostics.cs`. | none |
| `ACDREAM_DUMP_VITALS` | Issue #5 `PrivateUpdateVital`/`PlayerDescription`/parse-failure trace across 4 sites. #5 closed 2026-04-25; stripped 2026-08-24 (#435 part 2). | none |

View file

@ -979,7 +979,7 @@ Research: R7 + R10 + R11 + UI slice 05.
- **✓ SHIPPED — H.1 — Chat window.** UI panel + all 6 wire opcodes (Channel, Tell, System, HearSpeech, HearRangedSpeech, TurbineChat). Wire layer + panel + outbound input + holtburger inbound parity + combat translator all shipped across I.1-I.7 on 2026-04-25. Targets `AcDream.UI.Abstractions`; will be reskinned when D.2b's custom retail-look toolkit lands.
- **H.2 — Allegiance.** Tree model + XP pass-up math + 5 allegiance chat channels + MOTD. See `r11-allegiance.md`.
- **H.3 — Emote scripts + quests + dialogs.** **Client scope COMPLETE 2026-08-21** (Campaigns QT + QJ, both user-accepted). The "122 EmoteType × 39 Trigger mini-VM" in this line describes the SERVER's job: per `r10-quest-dialogs.md` §1.3 the retail client stores no quest flag, evaluates no emote, and is never told a flag changed. It learns about quests three ways — dialogue strings the server already formatted, generic error toasts, and the contract tracker. The first two shipped earlier; the tracker, plus the Journal notebook and its index, shipped as the three-tab Journal panel (`RetailPanelCatalog.Journal` = 25). Start at `claude-memory/project_quest_journal_panel.md`.
- **H.3 — Emote scripts + quests + dialogs.** 122 EmoteType × 39 Trigger mini-VM. Contract tracker UI. NPC dialog rendered via chat with `<Tell:…>` markup. See `r10-quest-dialogs.md`.
- **H.4 — Character creation.** `0xE000002 CharGen` dat + 13 heritages + templates + appearance picker + preview renderer. See `r07-character-creation.md`.
**Acceptance:** create a character from scratch, talk to an NPC, get + complete a quest, gain XP that passes up to the patron.
@ -2070,10 +2070,6 @@ Native macOS graphical support is not committed by this track. The current
mandatory renderer requires modern OpenGL capabilities beyond Apple's native
OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. The [Stage 2 connected and closeout gate](../research/2026-08-22-atmospheric-stage2-connected-gate.md) now closes every available machine-local ACE, dense A/B, long-lifetime, graphical-package, shader, Release, and complete-test row. The implemented two-client gate proved that ACE rejects two concurrent characters on the one available account and is ready for separate observer credentials without recording secrets. A distinct-account remote-player row, unavailable physical GPU classes, and final project-owner pack-off/pack-on acceptance remain external gates; Campaign AR is not yet declared shipped.
**SHIPPED 2026-08-23 — Campaign VM (VisualMaster):** the [VisualMaster campaign](2026-08-22-visualmaster-campaign.md) closed the [Campaign AR review](../research/2026-08-22-campaign-ar-review.md) findings with evidence: VM0 proved the pack-off path is the pre-campaign renderer (pixel identity on static content, CPU 13 %, allocation 25×); VM2 cdb-read retail's live detail path (single-pass texture stages, the z-fade dead for built meshes) and VM1 re-ported #226 to it; VM4 made the documents truthful (real hardware darkens, the fallback brightened); VM5 de-banded the volumetrics (#421 filed); VM3 put the post stack in linear light with a numerically neutral preset; VM6 added weather-driven foliage wind behind the render pack (scenery-only, cutout/trunk motions, casters follow, independent of the shadow gate) through five Opus review rounds and a repeat-floor pixel apparatus ([note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md)). VM7's automated gates ran 2026-08-23 on `99d5b2a6`: release gate 15,283/0/0, connected lifecycle/reconnect route PASS pack-off and pack-on, final pack-off vs `base+normals` robust diff 510 px, #422 not reproducible in 40 instrumented runs. The owner's VM3 (linear light) and VM6 (wind) live gates passed 2026-08-23 at 2560×1440 with the High pack; two defects found during those gates were fixed the same session (#424 alt-tab zero-area frame crash, #425 resolution-blind resident budget + activation lockout). #422 (rare heap-corruption exit at process close, pack-independent) is carried open by the owner's decision. Merged to main as a fast-forward of 54 commits.
---
## Cross-cutting work tracked in parallel
@ -2119,7 +2115,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| Sliding along buildings / walls feels wrong | **Phase L.2c + L.2d** |
| Roof edge / cliff / precipice blocks or slides wrong | **Phase L.2c** |
| Crossing outdoor cell seams reports the wrong cell | **Phase L.2e** |
| Can't talk to NPCs | NPC dialogue works (user-confirmed 2026-08-21); the emote VM behind it is the SERVER's, not ours |
| Can't talk to NPCs | Basic select/use/give interaction works; full emote conversation/dialog systems remain **Phase H.3** |
| Can't open a door | **FIXED** ✓ — object-use, animation, fading hooks, and collision transitions shipped |
| Portals render as a rotating black disk | **FIXED** ✓ — DAT particles/effects and portal-space presentation shipped |
| Chimneys have no smoke | **Phase E.3 SHIPPED** ✓ |
@ -2139,6 +2135,6 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| No character creation — must use ACE admin | **Phase H.4** |
| Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) |
| Can't join allegiance | **Phase H.2** |
| ~~No quest tracker~~ | **SHIPPED 2026-08-21** — the Journal panel's Contracts tab (Campaign QT) |
| No quest tracker | **Phase H.3** |
If you see something not on this list, add it here and assign a phase.

View file

@ -184,28 +184,17 @@ panel through `IPanelRenderer`.
## Plugin UI API
The shipped plugin-facing gameplay UI contract is the additive BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable
window id/title/icon descriptor, KSML-style markup, and a binding object; the
host builds it into the retained `UiRoot` tree. The API-v1
`AddMarkupPanel` member remains source/binary compatible and is enriched into
the same first-class window route by the scoped host. `IPanel`/
`IPanelRenderer` remains a historical first-party developer-panel contract and
is intentionally not referenced by `Plugin.Abstractions`.
The shipped plugin-facing gameplay UI contract is
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides
KSML-style markup and a binding object; the host builds it into the retained
`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel
contract and is intentionally not referenced by `Plugin.Abstractions`.
This makes plugin gameplay panels presentation-assembly independent while
allowing them to share the retained input, window, and DAT-sprite runtime.
Registrations made before the graphical host exists are buffered. The host
assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common
window manager, persists its geometry/visibility, and exposes it through the
shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the
plugin. No-window hosts retain the plugin session but expose the no-op UI
capability.
The retained markup vocabulary includes panels, nested groups, labels,
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
BCL-visible properties/actions on the plugin binding object; visible controls
must correspond to real behavior, never placeholders that report success.
This makes plugin gameplay panels independent of ImGui while allowing them to
share the retained input, window, and DAT-sprite runtime. Registrations made
before the GL host exists are buffered. In builds where retail UI is disabled,
they remain registered but have no gameplay surface; the long-term release
configuration enables retained gameplay UI.
The following was the original pre-D.2b proposal and remains historical
context, not the shipped plugin contract:
@ -266,8 +255,7 @@ walk around / take damage / regen.
### Sprint 3 — Plugin API hardening (superseded shape)
- Document the `IPanel` contract.
- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the
compatible legacy entry), not plugin-owned
- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned
`IPanel` implementations.
- Confirm plugins can subscribe to game events and expose retained markup
bindings without referencing App or ImGui assemblies.

View file

@ -503,12 +503,6 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value.
**Unset — the default, and every ordinary run — keeps the wall clock**, so
nothing the user or the offline gate sees changes unless a gate asks.
**2026-08-28 follow-up:** the ordinary sky-animation source is now a monotonic
`Stopwatch` clock, matching retail's accumulated timer deltas and preventing an
OS time correction from jumping rain/cloud UVs. The V7 pin and its gate
semantics are unchanged; the wall-clock wording above describes the original
V7 implementation.
It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override
pin only the *other* sky clock: the Dereth date, which chooses the day group,
the keyframe and the sun angle. The cloud sheet does not read that clock at all
@ -2597,10 +2591,6 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was
`-MaskTopPixels`, which would have permanently blinded the campaign's strictest
instrument to the entire sky.** See §5.1.
The 2026-08-28 rain-timing parity follow-up replaced that adjustable wall clock
with monotonic `Stopwatch` elapsed time; this historical V7 measurement and the
diagnostic pin remain otherwise unchanged.
**3. The world clock was never pinned at all, and that was most of the number**
(`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times.
The mechanism underneath is `WorldTimeService.SetDebugTime`, and

View file

@ -129,9 +129,9 @@ implementer per slice against a pinned contract (per
dissolved with the `RetailWindowChrome.Imported` mount (0x2100006F's
own border art IS the window chrome — no nine-slice wrapper, no
content crop, frame==content) using the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000. The originally deferred AP-185
`_Locked` cosmetic border-art swap was subsequently ported through the
shared registered-window lock presenter on 2026-08-20.
minH=100/maxH=2000/minW=300/maxW=2000. Register row AP-185 files the
one accepted simplification (the `_Locked` cosmetic border-art swap on
`UiLocked` is not ported; the live grip skin shows unconditionally).
- **CH6b — floating windows 14.** Mount `0x2100005B` ×4 as
always-resident children per `gmGamePlayUI::SetupChildren
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);

View file

@ -94,9 +94,8 @@ DAT-authored values.
register row for the ACE-sourced 2013-unverifiable mapping.
- **D4 — Configure Keyboard is the campaign's rebind screen** (it is the
ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap
data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail
`.keymap` Load File / Save As/startup/shutdown persistence now ships;
`keybinds.json` remains only the host-command compatibility mirror.
data (lane D Option C) but persist to `keybinds.json`; retail `.keymap`
file interchange is a register-row deferral.
- **D5 — dead-endpoint buttons short-circuit to their own retail failure
strings.** Urgent Assistance / Report Abuse open a defunct
`support.turbine.com` URL in retail; acdream skips the browser launch and
@ -361,8 +360,8 @@ modal capture; right-click erases; N-way cross-map conflicts + the
non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults
reloads the DAT maps. Persistence: `keybinds.json` (D4).
**Register rows:** any retail column/behaviour consciously narrowed. The
former D4 `.keymap` deferral was retired by #446 on 2026-08-26.
**Register rows:** `.keymap` file interchange not implemented (D4); any
retail column/behaviour consciously narrowed.
**Gate:** connected — rebind a movement key, conflict prompt on a taken
chord, persistence across relaunch, reset restores retail defaults.
@ -395,8 +394,7 @@ chord, persistence across relaunch, reset restores retail defaults.
`2026-08-09-chat-retail-window-shell.md` §6.3's register row.
- A pre-world character-select flow (D6 adapts; its register row carries
the future work).
- None for retail `.keymap` file read/write; #446 implemented it on
2026-08-26 and retired AP-202.
- Retail `.keymap` file read/write (D4 register row).
- The `0x21000017` docked `gmPanelUI` host variant — acdream ships the
floating host only (register row in OP3 if the review deems it a
divergence; retail exposes both).

View file

@ -1,334 +0,0 @@
# Campaign LU — launcher usability
**Status: CLOSED USER-ACCEPTED 2026-08-19/20.** Ten slices — the six planned
plus four the gate rounds added — shipped through CI and accepted live.
**Gate results, in the user's words:** the update flow "works, it updates as it
should"; the launcher self-update round "pass"; the client's exit back to the
character selector "pass".
| slice | commit | what it fixed |
|---|---|---|
| (blocker) #420 client crash | `a34e8f2a` | character select killed the client mid-paint |
| LU1 instant startup | `00d12782` | 29.9 s → 0.89 s, measured on the real 27.9 GiB pak |
| LU2/LU3 one update question | `a01ff426` | six buttons → Update / Not now, self-restarting |
| LU4 Setup complete | `0a2defb6` | setup ends with a dialog, not a finished progress bar |
| LU5/LU6 Play + sessions | `09305be6` | one Play per character; rows say who is playing |
| (cross-cutting) locale | `6a15dd06`, `955c6180` | retail text stopped following the machine's locale |
| headless CLI + LU7 | `2bff44a9` | headless and character refresh had never run at all |
| LU8 roster + fold | `18bbd377` | logging in IS the refresh; Play above the fold |
| LU9/LU10 stop + logout | `6ab5d8ce` | 30 s graceful stop, ACE hold, logout lands on select |
| verification-cache limit | `7037681a` | the ZFS finding below |
Full solution under the release-gate filter: **14,375 passed, 0 failed,
0 skipped**, and identical under `sv-SE`, `tr-TR`, `ar-SA` and `de-DE`.
---
## What the gate rounds found that the plan did not
Four of the ten slices did not exist when this plan was written. Each came from
the user running the thing, and each was a defect the automated suite could not
have surfaced:
**Headless and character refresh had never worked, once.** The launcher spawned
`acdream-headless --config <path>`; the host reads `arguments[0]` as its command
and accepts only `validate` or `run`. Every launcher-started headless session
and every roster refresh died on its first instruction with "Invalid command"
and exit 64 — visible only as a code in a status file. A whole campaign's gates
missed it because they drove the headless host through its CLI directly, never
through the launcher's spec. `LauncherHeadlessCommandLineContractTests` now
feeds the launcher's real argument vector to the host's real parser.
**Refresh was harmful as well as broken.** It opened a second connection to an
account purely to read the roster, which the server treats as a new login — so
using it while playing disconnected you. It was also redundant: every ordinary
login already carries the roster, and the orchestrator already folds it in.
**Stop was the crash.** The UI gave the client five seconds before killing it,
which is not enough to send a logout, await the acknowledgement, and tear down a
mapped 28 GB world. So Stop routinely produced exactly the ungraceful exit that
leaves the server holding the account.
**Play was below the fold.** The buttons existed; the plugins/login-commands
form pushed them past the bottom of the scroll area. Reported, correctly, as
"there is no headless or gui option".
## Findings worth keeping
**The verification cache cannot see a same-size, same-timestamp change.** Run
174 failed on a test asserting it could. Measured on the runner: `/tmp` is ZFS,
and 141 of 200 same-size rewrites produced an identical mtime. NTFS's 100 ns
resolution is the only reason it never showed on Windows. The contract is now
two true statements — startup catches a corruption whose write time moves, and
a forced full verification catches one that preserves both — instead of one
that is false on some filesystems. Verify files is the forced path.
**Testing the launcher does not test your source.** The launcher runs the
INSTALLED client from the version store, so a client-side fix cannot be gated
until CI publishes it. A void-world screenshot was read as "the fix failed" when
the installed build was 63 minutes older than the fix.
**A locally built launcher cannot test self-update.** Its stamped version is
`1.0.0`, which sorts above every `0.1.0-build.*` the feed publishes, so it is
never offered an update. Publishing one with a deliberately low
`InformationalVersion` is what made that path testable at all.
**Goal**
> The launcher opens without a long wait. On startup it asks whether to
> update the launcher or the client, and restarts itself after a launcher
> update; the old update flow is gone. First-run setup ends with a success
> popup that returns you to the launcher on OK. A selected character
> launches directly. The sessions frame shows account, character (or Char
> Select) and whether they are in game — not the launch mode.
**Why now.** Campaign LA shipped a launcher that is *correct* — atomic
installs, verified artifacts, session barriers, rollback — and *not
usable*. The user's verdict, twice: "way too complex", "too complex for
sending it to my friends". This campaign changes the surface a person
touches. It does not weaken what happens underneath.
**Acceptance for the whole campaign** is the user's own walkthrough:
download `launcher-win-x64.zip` from the `latest` release, unzip, run,
install, play — without being told anything.
---
## LU1 — the launcher opens immediately
**Measured problem.** [App.axaml.cs:57](../../src/AcDream.Launcher/App.axaml.cs)
blocks the UI thread on `installer.LoadExistingAsync().GetAwaiter().GetResult()`
before the window is constructed. That reaches
`LauncherInstallRecordStore.VerifyFileAsync`, which computes a full SHA-256
of the installed package.
Measured on the user's machine 2026-08-19:
| fact | value |
|---|---|
| `%LOCALAPPDATA%\acdream\pak\acdream.pak` | 29,908,271,024 bytes (27.9 GiB) |
| full SHA-256 | **24.1 s** at 1.16 GB/s |
| digest vs `install.json` | identical (`fee8595d…`) |
So the startup cost is 24 s of disk read to re-confirm something that was
already true. A friend does not see it only because they have no package
installed yet — verification short-circuits at "nothing installed". It
will hit them the moment first-run setup finishes.
**Change.** Startup verification becomes size + last-write-time against
the record. The full hash keeps running where it is cheap and meaningful:
at install, after an update installs a new package, and behind an explicit
**Verify files** button (the Steam shape).
The cheap facts live in a **sidecar** (`install.verification.json`), not as a
new field on the install record. `LauncherInstallRecordStore` reads
`install.json` with `JsonUnmappedMemberHandling.Disallow`, so a new field
there would make an *older* launcher build reject the record outright and
demand a 28 GB re-bake after a rollback. An unknown sidecar file is simply
ignored by older builds, so the change is compatible in both directions.
An install with no sidecar yet pays one full hash and then writes it.
**Acceptance**
- Window visible in under 2 s with the 27.9 GiB package installed.
- Truncating or touching the package still blocks launch with a clear reason.
- **Verify files** reproduces the full check and reports pass/fail.
- The install and update paths still hash in full — unchanged.
---
## LU2 — one update question, asked once, at startup
**Change.** On start the launcher checks the feed once. If the launcher or
the client is behind, it shows **one** dialog naming what is out of date and
offering **Update** / **Not now**. Nothing else.
- Launcher first when the feed's `minimumLauncherVersion` demands it, or
when only the launcher is behind: install, then **restart into the new
version** (`LauncherSelfUpdateBootstrap` already owns this handoff).
- Client otherwise: install, close the dialog, back at the launcher.
- Nothing to do: no dialog at all. The launcher just opens.
**Acceptance** — three observed cases: up to date (silent), client behind
(one dialog → play), launcher behind (one dialog → relaunched on the new
version, confirmed by the version it reports).
---
## LU3 — delete the old update surface
The current prompt offers six buttons — Check again, Rollback client,
Stage launcher, Install client, Cancel, Close — plus a version table and a
restart-required banner. That is the flow being removed, along with the
"Check for updates" header button and the `LauncherUpdateViewModel` paths
only it reached.
**What stays:** everything in `AcDream.Launcher.Core/Updates/` that makes
an update safe — manifest validation, bounded verified download, safe ZIP
extraction, versioned install with an atomic `current.json` switch, the
session barrier, and rollback as a *capability*. The complexity the user
objects to is the panel, not the safety beneath it.
**Open — needs one confirmation before code is deleted:** rollback has no
place in the new single-question flow. It can move behind a small
"Advanced" affordance or leave the UI entirely (staying available as Core
API + tests). I will show the exact deletion list and ask before removing
it.
**Acceptance** — exactly one update entry point in the UI; tests covering
deleted view-model behavior are removed with the code, never skipped.
---
## LU4 — "Setup complete" ends first-run setup
**Change.** When the bake publishes and the install record verifies, the
wizard shows a modal: setup succeeded, what was built, **OK**. OK closes
the wizard and returns to the launcher with the "Client setup required"
banner gone and launching enabled.
**Acceptance** — a real first-run bake shows it exactly once on success;
cancellation and failure paths keep their existing error/status reporting
and must **not** show it.
---
## LU5 — pressing Play on a character launches that character
**Reproduce before changing anything.** The plumbing already exists end to
end: `LauncherOrchestrator.LaunchAsync` clones the character with the
*requested* mode (`CloneCharacter(character, mode)`),
`SessionConfigComposer.BuildSelector` emits an id selector (falling back to
name), and `RuntimeOptions.MapCharacterSelector` maps it into the App host.
A defect somewhere in a chain that reads correct is exactly the case this
project has repeatedly lost time to by guessing.
Two candidates to separate by observation, not argument:
1. The launch button is gated off by a capability reason, so the click
never becomes a session.
2. The selector reaches the client but the roster match fails, so character
select stays on screen — which is what "you can just select different
chars" describes.
**Change.** One obvious **Play** per character that enters the world as
that character, plus the deliberate "Character select" path kept separate.
Three near-identical launch buttons is itself part of the complaint.
**Acceptance** — select a character, press Play, arrive in the world as
that character with no character-select screen in between.
---
## LU6 — the sessions frame says who is playing
Today each row reads `server / account / character`, then `Mode`
(Gui/GuiSelect/Headless/Probe), then `State`, then a raw status string.
The launch mode is launcher bookkeeping and means nothing to a player.
**Change.** Each row shows the account, the character — or **Character
select** when no character was chosen — and one plain status word derived
from the host's own status stream:
`Starting``Character select``In game``Stopped` / `Failed`
Errors keep their own line. Stop keeps its button. Character-refresh
(probe) rows stay distinguishable from play sessions.
**Acceptance** — launching a character shows account + name + **In game**
once in world; a character-select launch shows **Character select** until a
character is entered.
---
## Non-goals
- No change to download verification, atomic install, or the session barrier.
- No change to credential handling (plaintext profile remains the user's decision).
- No change to Linux graphical gating (Slice L stays parked).
## Working rules for this campaign
- One slice per commit, `dotnet build` + `dotnet test` green before each.
- Push to main; CI gates on both runners and publishes the release the
launcher itself updates from — so every slice is testable by the user
through the shipped path within a few minutes.
- LU3's deletions and LU5's root cause get shown to the user before they
land.
---
# Implementation notes (recon 2026-08-19, before any code)
These were read out of the tree, not assumed. They exist so each slice
starts from the mechanism that is already there instead of re-deriving it.
## The self-update restart chain already exists end to end (LU2)
`LauncherUpdater.StageLauncherAsync` stages a verified payload and writes a
plan. On the next ordinary startup `LauncherSelfUpdateBootstrap.HandleAsync`
takes the exclusive lease, sees `SelfUpdatePlanState.Staged`, and spawns the
STAGED launcher in helper mode. `RunHelperAsync` waits for the parent PID to
exit, applies the replacement, starts the updated launcher with
`--acdream-self-update-confirm-v1`, and waits for the confirmation receipt.
So "restart after a launcher update" needs no new update machinery. What it
needs is one seam: after staging succeeds, start the staged helper against
the CURRENT process and shut down. Extract the existing staged-plan branch of
`HandleAsync` into a callable entry point and reuse it — do not duplicate it,
and do not restart by launching a second copy of the launcher and hoping the
bootstrap picks the plan up, which races the exclusive lease against the
process that is still shutting down.
## The orchestrator already knows "in game" (LU6)
`LauncherActivityState` has `InWorld`, and the orchestrator already sets it
from `EnteredWorldStatusEvent`, which carries the real `CharacterId` and
`CharacterName` from the host. Today that identity is written into a status
STRING (`"In world as X."`) and thrown away.
LU6 promotes it: the entered-world event updates the activity's character
name so a character-select launch can show who is actually being played, and
the row renders one word derived from `LauncherActivityState` rather than the
raw enum plus the launch mode:
| state | row shows |
|---|---|
| `Starting`, `Running` | Starting |
| `Connected` | Character select |
| `InWorld` | In game |
| `Disconnected`, `Stopping` | Stopping |
| `Exited`, `Cancelled` | Stopped |
| `Failed` | Failed |
`LauncherActivityKind.Probe` rows stay visually distinct (they are a
character refresh, not a play session).
## First-run completion has an exact point (LU4)
`FirstRunInstallerViewModel.StartAsync` succeeds at the line that calls
`_onInstalled(result.Record)` and sets `Phase = LauncherInstallPhase.Completed`.
That is where the success dialog belongs — after the record is published, so
the launcher behind it is already in its launch-enabled state when the user
presses OK. The cancelled and failed branches immediately below it must not
reach it.
## The launcher side of "launch this character" reads correct (LU5)
Confirmed by reading, so the live repro can skip re-checking these:
- `LauncherOrchestrator.LaunchAsync` -> `CloneCharacter(character, mode)`
overrides the profile's saved `LaunchMode` with the mode the button asked
for, so the stored default cannot leak into an explicit launch.
- `SessionConfigComposer.Compose` builds a selector for every mode except
`GuiSelect`, preferring a parsed non-zero id over the name.
- `SessionPlayerComposition` passes the selector into
`LiveSessionConnectOptions` with `AwaitCharacterSelection: selector is null`,
and `InteractionRetainedUiComposition` binds the character-selection UI only
when the selector is null.
The user's stored profiles all carry `launchMode: "guiSelect"` (the default),
and every cached character has a real id. So the defect is NOT a missing id
and NOT the saved default overriding the click. Reproduce live before
changing anything.

View file

@ -1,977 +0,0 @@
# Campaign AR — Atmospheric Rendering / Shader Packs
**Date:** 2026-08-21
**Status:** MACHINE-LOCAL STAGE 2 COMPLETE — the authored sun-and-moon
shadow-source extension, Stage 1 owner gate, connected ACE matrix, dense A/B,
long lifetime, graphical package lifecycle, shader, locked-restore, Release,
and complete-test gates pass. A distinct-account second-client remote-player
row, unavailable physical GPU classes, and final project-owner pack-off/pack-on
acceptance remain external closeout gates; the campaign is not yet declared
shipped.
**Phase id:** **Campaign AR** — assigned by the project owner on 2026-08-22
**Scheduling:** originally held for the post-M7 rendering-polish pass; the
project owner explicitly authorized implementation on 2026-08-21. This
owner-directed campaign is now Campaign AR and does not displace the active M4
gameplay work. The previously referenced #268 + TS-8 stat-chain package is
already complete and retired, so it is no longer a scheduling dependency.
## Goal
Add an opt-in enhanced-graphics system whose headline feature is **real-time
directional shadows cast by trees, monsters, players, and buildings as
Dereth's authored sun and moons move across the sky**. The same system can add
bloom, filmic tonemapping, colour grading, vignette, sun rays, and later
volumetric shafts, with useful quality levels on weak through high-end
hardware. Sun rays and volumetric shafts remain sun-only effects; the approved
moon scope applies to Tier 2 directional shadows.
Campaign AR executes in two stages. Stage 1 fixed the dense-scene transform
ceiling and shadow quality, completed authored sun/dominant-moon/secondary-moon
source selection, and finished every automated gate that did not require the
project owner's physical-display judgment. The owner accepted the subsequent
live visual/performance round on 2026-08-22 after the default Atmospheric
exposure was corrected from 1.0 to 0.80. The machine-local Stage 2 rows are now
complete. Remaining closeout is limited to a second connected remote player on
a distinct ACE account, unavailable physical-hardware rows, and final owner
acceptance; it does not add another renderer feature tier.
Stage 1's dense-scene regression is pinned by the connected failures already
captured on 2026-08-22: Atmospheric fell back at 68,395, 67,581, and even
65,538 combined world matrices against the old 65,536-matrix binding ceiling.
The corrected connected launch must exceed that historical workload without
persisting acdream-default fallback or splitting the authoritative pose data.
The enhancement is a shader pack, not a rewrite of acdream's renderer or AC's
art. **acdream's current retail-faithful renderer** remains the default,
authoritative path. References below to the “default” or “retail-faithful” path
always mean acdream—not the original retail executable.
The evidence and constraints for this design are recorded in the
[terrain and atmospheric rendering findings](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md),
especially [the measured renderer baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
and [the requested tier model](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#6-wanted-work--atmospheric-rendering-user-stated).
The approved celestial identity, priority, transform, and direction-versus-
energy contract are pinned by the
[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
## Opt-in contract
1. **acdream's current renderer is the default.** With no pack selected, the current render graph,
shaders, render targets, submissions, lighting, colours, and screenshots
remain authoritative. No enhancement resource or pass is created.
2. **Selection is explicit.** Installing a pack does not enable it. The user
selects one pack and one quality preset in Display settings. `acdream
default (retail-faithful)` is always present and cannot be removed.
3. **One pack owns the enhancement graph.** Packs do not stack. This prevents
ambiguous pass ordering, incompatible HDR conventions, and unbounded GPU
cost.
4. **The renderer owns the RHI.** A pack declares assets, semantic pass hooks,
capabilities, resources, and quality variants. It never receives Vulkan
handles or mutates the authoritative scene, streaming, gameplay, or
physics owners.
5. **Failure returns to acdream's default renderer.** Unsupported capabilities,
malformed assets, shader/pipeline candidate-creation failure, or an invalid
pass graph disables the complete pack and restores acdream's default path
with a visible reason. A half-enabled pack is never rendered. A terminal
`VK_ERROR_DEVICE_LOST` cannot render either path on the lost device; it tears
down that renderer/device lifetime, and retail remains authoritative while a
fresh renderer/device is constructed and the pack is validated again.
6. **Divergence is honest.** Enhanced screenshots are intentionally not retail
parity evidence. The default path remains the comparison oracle and the
enhancement choice is recorded in diagnostics and screenshot metadata.
7. **No scheduling claim.** Rendering phases stay frozen until the M7 polish
pass unless the project owner explicitly reprioritizes this work.
## Capability tiers
The costs below are **planning estimates**, not measurements. They are
incremental GPU p50 targets for a representative discrete GPU at 1920x1080;
every slice must replace them with physical-hardware measurements. Tier-1
pixel effects scale with output resolution, so 4K has roughly four times the
1080p fragment workload. Shadow-map cost depends more on caster count, map
resolution, and cascade count than on output resolution.
| Tier | Contents | Prerequisite | Rough incremental GPU cost at 1080p |
|---|---|---|---:|
| acdream default | Current authoritative retail-faithful rendering | Current mandatory Vulkan/RHI capabilities | 0 ms |
| 0 | **Weather-driven foliage wind** (Campaign VM VM6): procedural-scenery trees/bushes lean, branch-swing, and flutter; their directional-shadow casters apply the identical displacement so the shadow moves with the leaf | Shader ABI v2 (`AtmosphericFrame` gains `uAtmosphereClockWind`/`uAtmosphereWindAmplitude`); the existing `ProceduralSceneryIdAllocator` entity-id namespace for classification | ~0.05 ms (near-zero; ~25 ALU per displaced vertex, zero CPU cost, zero extra draw submissions — an early-out `uint` AND gates every non-foliage vertex) |
| 1 | Bloom, ACES filmic tonemap, colour grade, vignette | Main-world colour intermediate and fullscreen passes | 0.350.80 ms |
| 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.200.50 ms |
| 2 | **Moving authored sun-and-moon cascaded directional shadows from trees, monsters, players, and houses/buildings** | A second scene pass, selected-celestial view/projection matrices, sampled depth maps, caster pipeline variants | 1.503.00 ms |
| 2+ | Sun-only volumetric light shafts | Reuse Tier-2 shadow infrastructure only while the selected source is the authored sun, plus authored weather | 0.150.40 ms |
| Later | SSAO and water reflections | Scene depth plus normal inputs and separate designs | Not budgeted here |
| Out | True PBR | AC lacks authored per-texture normal/roughness/metalness maps | Not planned |
Tier numbers express prerequisites, not a forced bundle. A pack may offer
Tier 1 without shadows. Tier 2 always includes the complete shadow-caster
classes; weak-hardware presets reduce range, cascade count, and resolution
rather than silently dropping monsters, trees, or buildings.
**Foliage wind (Tier 0, Campaign VM VM6).** Deliberately numbered below Tier
1: it needs no fullscreen pass, no colour intermediate, and no shadow
infrastructure of its own — only the shader ABI v2 clock/wind block and the
per-batch classification bits `BatchData.flags` already carries. It composes
with every other tier (Tier 2's shadow casters read the same displacement so
a swaying tree's shadow tracks it) and, uniquely among these tiers, is cheap
enough that a future default-on consideration is plausible; today it ships
opt-in with the rest of the atmospheric pack, gated by `wind-enabled`.
## Tier 2 headline: Dereth's moving authored sun-and-moon shadows
The [measured renderer state](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
already supplies retail's single directional colour/energy channel from
`SkyStateProvider`. Tier 2 augments it with the visible authored celestial
positions documented in the
[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
The camera-relative cascaded map selects, in order, the visible above-horizon
sun (`0x01001348`), dominant haloed moon (`0x01001F6A`), or secondary moon
(`0x01001F67`). The selected mesh's exact rendered transform supplies shadow
direction; retail's one interpolated `DirColor * DirBright` channel supplies
colour/energy. Moon texture brightness and mesh luminosity never manufacture a
second world light. As those authored bodies move, tree branches, monsters,
players, houses, and other eligible world geometry cast correspondingly moving
shadows.
This source selection is an explicit opt-in pack enhancement. It is not a
claim that the retail executable rendered real-time moon shadows, and it does
not alter acdream's default retail-faithful scene lighting. Screen-space sun
rays and volumetric shafts continue to use only the authored sun; they do not
switch to either moon.
The required behavior is:
- Terrain and opaque world geometry receive shadows. Terrain, buildings,
statics, procedural scenery, the local player, remote players, and creatures
cast them when resident and visible to the main outdoor world.
- Foliage and other cutout materials use an alpha-sampling shadow fragment
shader. An empty depth fragment shader would turn each tree plane into a
solid rectangular shadow.
- Animated casters reuse the exact per-part transforms already published in
the N.5 SSBO. The shadow pass must not create a second animation pose or
gameplay entity projection.
- Cascades follow the camera and are texel-stabilized. Their reach is clamped
to the resident two-tier streaming window; the pack does not extend world
streaming or issue speculative loads.
- The celestial directional-shadow pass is outdoor-only. Dungeon and EnvCell lighting remains
authored per-cell lighting. Entering an interior retires or idles outdoor
shadow work without leaving stale maps on screen.
- Shadow direction follows the selected visible above-horizon sun, dominant
moon, or secondary moon. The authored directional colour/energy remains
`DirColor * DirBright`; active day/weather pack policy may soften or reduce
it without inventing a second celestial clock, light-energy channel, or
weather system. A time with no eligible above-horizon body has no
directional shadow; night is not itself a disable condition.
- Transparent blend materials do not cast an opaque silhouette by default.
Only existing opaque and cutout classifications participate until a
material-specific transparent-shadow contract is designed.
The pass reuses the retained resident scene. It must not run PView, portal
traversal, or per-object CPU visibility classification once per cascade.
Initially, each cascade draws the bounded resident caster set through the
existing batched/MDI ownership. If that is too expensive, the next permitted
step is GPU culling—not repeated CPU culling or per-object submissions.
## Pack API surface sketch
The public declarations belong in the BCL-only
`AcDream.Plugin.Abstractions` assembly. The graphical App supplies the
implementation and translates the declarations to the Vulkan RHI. Headless
hosts expose no render-pack registry and never load pack assets.
This is an API shape, not code committed by this design:
```csharp
public interface IRenderPackPlugin
{
void Register(IRenderPackRegistry registry);
}
public interface IRenderPackRegistry
{
IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets);
}
public interface IRenderPackAssets
{
Stream OpenRead(string assetKey);
}
public sealed record RenderPackDescriptor(
string Id,
string DisplayName,
Version PackVersion,
int PackApiVersion,
RenderPackTier HighestTier,
IReadOnlyList<RenderCapability> RequiredCapabilities,
IReadOnlyList<RenderCapability> OptionalCapabilities,
IReadOnlyList<RenderResourceDeclaration> Resources,
IReadOnlyList<RenderPassDeclaration> Passes,
IReadOnlyList<SceneReplayDeclaration> SceneReplays,
IReadOnlyList<PipelineVariantDeclaration> PipelineVariants,
IReadOnlyList<RenderQualityPreset> QualityPresets,
IReadOnlyList<RenderSettingDeclaration> Settings,
AtmospherePolicyDeclaration? AtmospherePolicy);
```
A pack declares:
- a stable ID, display name, pack version, and pack-API version;
- its highest tier and a human-readable feature summary;
- mandatory and optional GPU capabilities and per-preset limits;
- shader assets and fixed renderer semantic inputs, including world colour,
scene depth, optional normals, selected celestial shadow direction/energy,
sun direction/screen position for sun-only effects, active weather, camera
matrices, shadow-caster transforms, and frame time;
- intermediate images/buffers by relative or absolute extent, format class,
usage, lifetime, and estimated bytes;
- passes at renderer-owned hooks such as `ShadowDepthBeforeWorld`,
`AtmosphereBeforeToneMap`, `ToneMap`, and
`AfterToneMapBeforePrivateViewports`;
- renderer-owned scene replays such as `OutdoorDirectionalShadowCasters`, with
requested cascade views and existing caster/material classes
(`Terrain`, `OpaqueWorld`, `AlphaCutoutWorld`, `AnimatedOpaque`, and
`AnimatedAlphaCutout`); the renderer resolves those classes from its
retained scene and records their existing batched draws;
- fixed pipeline variants for shadow-caster depth and main-world shadow
receivers. A variant names its base semantic (`Terrain`, `WorldMesh`, or
`EnvCell`), shader asset, compatible material classes, and declared inputs
such as cascade matrices, directional depth maps, and sampler state; it does
not replace visibility, batching, mesh ownership, or draw submission code;
- quality presets, user-visible settings with bounded ranges, and declared
incremental GPU/VRAM budgets; and
- an atmosphere policy: explicit directional-source/sun-elevation response
curves and a mapping from AC's categorical `activeDayGroup` values to effect
multipliers. These values live in the visible pack declaration, not as hidden
renderer constants; AC remains the owner of celestial position, directional
energy, and weather state.
The renderer—not the pack—defines descriptor layouts, validates SPIR-V and
resource declarations, resolves semantic scene-replay and pipeline-variant
requests, builds pipelines, schedules barriers, owns frame-flight and teardown,
and supplies immutable frame inputs. Packs cannot add arbitrary draw callbacks,
read gameplay owners, submit command buffers, retain borrowed frame views, or
address resources outside their registration. The built-in pack's Tier-2
caster pass and receiver shaders must be expressible entirely through these
same public declarations.
### Selection and fail-safe lifecycle
1. Discover manifests and descriptors without creating GPU objects.
2. Show compatibility and estimated cost in Display settings. Unsupported
packs remain visible with the exact missing capability; they cannot be
selected.
3. On explicit selection, validate the whole descriptor, all assets, resource
ceilings, hooks, and shader interfaces; then build a complete candidate
pipeline set off to the side.
4. Atomically activate the candidate only after every required object exists.
Until then acdream's default path continues rendering.
5. Persist `pack id + pack version + preset`, never a positional index. If the
pack disappears or becomes incompatible, select `acdream default` and
retain the failure notice.
6. On runtime validation or post-recreation candidate failure, withdraw all
pack passes/resources at a frame boundary and resume acdream's default
renderer. Do not repeatedly retry a failing pack during the session.
7. Unload and reconnect use the normal render-generation and GPU-flight
retirement rules. No pack object may retain a world generation, scene
entity, or collectible plugin load context.
In this campaign, **device recreation** means disposing the complete old
renderer, Vulkan context, and device, then constructing a fresh context/device,
re-probing capabilities, and validating selection again with retail active
until the candidate is complete. It does **not** mean live, in-process recovery
from `VK_ERROR_DEVICE_LOST`; device loss remains terminal to that renderer and
device lifetime.
The built-in Atmospheric Rendering pack should be the first consumer of this
same API. It must not receive private renderer shortcuts that third-party packs
cannot express.
The public v1 authoring surface, manifest schema, shader semantic bindings,
failure guidance, validator command, and external no-op sample are indexed by
the [render-pack SDK](../render-packs/README.md).
## Frame-graph placement
With the pack off, the frozen retail graph is unchanged. With a pack selected,
the renderer builds a separate enhancement graph:
1. Update the existing immutable world frame, including authored sky objects,
the retail directional colour/energy channel, and weather.
2. Outdoors, select the visible above-horizon sun/dominant moon/secondary moon
direction and render Tier-2 cascaded shadow depth from the resident caster
set.
3. Render the main world to the pack's world-colour intermediate, using
pack-selected pipeline variants to sample the shadow map where requested.
4. Preserve the established PView, punch/seal depth discipline, shared-alpha
ordering, particle ordering, and world transparency boundaries.
5. Generate screen-space sun occlusion/rays or, only while the shadow source is
the sun, shadow-map volumetrics.
6. Composite rays/shafts **before tonemapping**, so bloom sees them and the
filmic curve rolls them off instead of clipping them.
7. Apply tonemap, colour grade, and vignette to the main world image.
8. Continue with private portal/paperdoll/appraisal viewports and retained UI
on their existing path. They are not accidentally post-processed with the
main world.
## Delivery slices and acceptance
The slice labels below are local to this document. They are not phase IDs.
### Pre-moon checkpoint, Stage 1 acceptance, and Stage 2 start — 2026-08-22
Before the approved moon extension, all seven local slices (06) had production
implementations in the current worktree. A source-identical isolated clean
snapshot closed that sun-only reference-GPU physical matrix, and one physical
integrated-AMD Auto safe-fallback row was also present. Those artifacts remain
valid evidence for the exact binaries and sun-only scope they measured; they
are not moon-alignment, source-transition, current-worktree, or final user-
acceptance evidence.
Stage 1's automated implementation and validation are complete. The authored
sun/dominant-moon/secondary-moon resolver and its direction-versus-energy
handoff are present and covered without inferring physical quality from unit
tests, screenshots, or historical sun-only rows. The subsequent live ACE round
covered the owner-reported shadow visibility/configuration, temporal
pixelation/shimmer, frame-pacing/desktop responsiveness, selection/fullscreen
regressions, and matched indoor/outdoor exposure. After the exposure correction
the owner accepted the live result. The exact evidence and limits are recorded
in the [Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
Current authored-celestial Stage 1 automated gate (2026-08-22):
- The shader compiler reports **24/24** Vulkan shader pairs ready. Incremental
regeneration expands only pack includes and preserves all 18 pre-campaign
retail SPIR-V artifacts byte-for-byte; the exact SHA-256 oracle and complete
source-manifest checks pass. All selected-celestial binding-6 modules expose
the 336-byte ABI v1 layout, including source kind at offset 320.
- Focused Release validation passes **344/344** App renderer tests, **30/30**
standalone SDK/pack-validator tests, **14/14** Core sky-loader tests, and
**48/48** MossTank tests. Both external Tier-2 samples embed and validate the
current selected-celestial shader ABI without App or Vulkan dependencies.
- The repository's forced locked restore passes. The complete Release solution,
including all source, tests, tools, and SDK samples, builds with **0 warnings
and 0 errors** after that restore.
- The repository-owned fresh-process hermetic gate passes
**14,928/14,928** tests with zero skips or failures across 14 assemblies
**under the repository's hermetic lane filter** (Manual/Timing/Live/
InstalledDat/Diagnostic lanes excluded; a raw `dotnet test AcDream.slnx`
reports those lanes as skips/failures by design — VM4 correction);
`AcDream.App.Tests` contributes **5,823/5,823**. Evidence is under
`artifacts/atmospheric-rendering/stage1-moon-release-gate/`.
- The App total includes the 9,500-caster 256-frame zero-managed-allocation
steady-state fixture, warmed CPU/GPU sampling allocation gates, the complete
12-cycle Low/Medium/High/retail/resize/failure/recovery/frame-flight/
generation convergence fixture, and independent renderer/context/device
recreation. These prove the non-physical performance and lifetime contracts;
they do not claim physical frame pacing or image quality.
- A final path audit finds no source changes under `src/AcDream.Runtime`, no
physics or collision changes, and no changes to the retail GLSL sources or
tracked retail SPIR-V binaries. Pack-off production integration remains the
strict authoritative-path oracle.
The command-level record and evidence boundary are in the
[Stage 1 automated gate report](../research/2026-08-22-atmospheric-stage1-automated-gate.md).
Recorded pre-moon automated checkpoint (not a current moon-scope completion
claim):
- The repository-owned fresh-process Release test stage passes
**14,880/14,880** tests with zero skips or failures across 14 assemblies
(hermetic lane filter, as above).
`AcDream.App.Tests` contributes **5,783/5,783**; campaign-focused App cases
cover descriptor/asset/SPIR-V
validation, pack-off/no-op invariants, atomic asynchronous candidate swaps,
runtime fallback, declared settings, Tier-1/Tier-2/Tier-2+ graph execution,
all headline caster classes, topology caching, exact animated transforms,
Low/Medium/High/Auto policy, diagnostics, and the pack UI.
- Headless plugin-session tests pass **6/6**, including rejection of a
render-pack-only request before its DLL is loaded.
- The SDK validator suite passes **26/26** and builds/validates the external
`AcDream.RenderPacks.NoOp`, `AcDream.RenderPacks.AtmosphericTier2`, and
`AcDream.RenderPacks.ShadowsOnlyTier2` samples without App or Vulkan
references.
- The production catalog is revisioned rather than frozen at startup. The same
composed controller/UI observes external registration, withdrawal, and
corrected re-registration; an active withdrawn pack retires at the next
frame boundary, persists retail fallback, releases its asset/context owners,
and does not retry the removed registration. Runtime package admission now
matches the SDK: exactly one public constructible render-pack entry point and
at least one live registration, with transactional rollback for malformed,
multiple, internal, zero-registration, and partially failing packages.
- The retained 9,500-caster warmed-frame fixture performs no second-frame
scene-index copy, topology rebuild, sort, or classification and allocates
zero managed bytes. Animated-static, live-dynamic, and equipped-child root
and part transforms refresh through cached IDs/slots with exact float bits.
- Render and screenshot diagnostics now publish exact accepted counts for
terrain commands, outdoor statics, buildings, animated statics, local
players, remote players, non-player creatures, other live dynamics, and
equipped children. These labels stop at the authoritative evidence boundary:
static DAT publication does not distinguish a tree from other outdoor
scenery, and create-object render metadata does not distinguish a hostile
monster from a non-hostile NPC creature. Diagnostics therefore report
`OutdoorStatics` and `NonPlayerCreatures`; they never infer tree or monster
identity from a mesh or ID.
- The recording-RHI long-cycle gate repeatedly crosses Low, Medium, High, and
retail selection; resize; injected candidate failure and explicit recovery;
both frame-flight slots; render-generation replacement; and final renderer
disposal. Pack resources, pipeline-format leases, texture slots, retained
transforms, receiver candidates, and registrations converge exactly. A
second fixture proves that device recreation is old-renderer/context/device
teardown followed by an independent fresh device and activation generation.
- Therefore the deterministic lifecycle implementation, recording-RHI
convergence, and fresh-device recreation definition are locally closed. The
executable connected route and its contract assertions are implemented for
select/disable/re-enable, exact resize, authored time/weather changes, and
fresh-process recreation, but a contract-tested route is not connected-world
evidence; its ACE-backed execution and artifacts remain open below.
- The complete Release solution, including all three SDK samples, the
validator, shader compiler/generated manifest, and repository tools, builds
with **0 warnings and 0 errors**. This historical checkpoint could not repeat
locked restore because that managed workspace denied NuGet access to the
user-profile `NuGet.Config`; the later Stage 1 moon gate and final Stage 2
gate supersede that limitation and both report forced locked restore green.
The corresponding durable source/test entry points are:
- public contracts and SDK:
`src/AcDream.Plugin.Abstractions/Rendering/`, `docs/render-packs/`,
`tools/RenderPackValidator/`, `samples/AcDream.RenderPacks.*`, and
`tests/AcDream.RenderPackValidator.Tests/`;
- activation, compatibility, Auto, diagnostics, and built-in graph:
`src/AcDream.App/Rendering/Packs/` and
`tests/AcDream.App.Tests/Rendering/Packs/`;
- moving authored-celestial cascades, casters, receivers, and retained topology:
`src/AcDream.App/Rendering/DirectionalShadow*.cs`,
`src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`,
`src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`,
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs`, and
`tests/AcDream.App.Tests/Rendering/DirectionalShadow*` plus
`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`;
- retained Display UI and headless boundary:
`src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs`,
`tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs`, and
`tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs`.
Machine-local offline/physical evidence currently present under
`artifacts/atmospheric-rendering/` is gate evidence, but it is not a substitute
for connected-world or project-owner acceptance:
- `smoke-retail-720p/` records the pack-off `retail/off` path with zero pack
resources, casters, cascades, or classification calls;
- `accept-shadow-morning-200m/`, `accept-shadow-afternoon-200m/`, and
`accept-shadow-morning-close/` contain fixed-camera moving-sun shadow
screenshots plus metadata for 9,498 resident casters and three cascades;
these pre-moon captures do not prove moon alignment or source transitions;
- `volumetric-valid-camera-500m/` contains a High-preset volumetric diagnostic
capture; it is not a performance acceptance row;
- `matrix-clean-snapshot-dense-linear-v20/` is the current complete AMD Radeon
RX 9070 XT physical matrix: **30/30 rows pass** across
retail/Low/Medium/High/Auto, 1080p/1440p/4K, and capped/uncapped pacing. The
actual worktree's 4,397 source files were copied and hash-verified into an
isolated clean snapshot at commit `4876c970`; the Release App product version
names that exact commit, source and binary identities match, and tracked
status is empty. Its 18 active enhanced rows each own exact 2,048-sample
CPU/receiver/GPU windows, 9,498 casters, and zero warmed classification calls.
Six rows are retail and six 4K Low/Medium/Auto rows are accepted
resource-unavailable fail-safe outcomes;
- `matrix-final-v16-exact-auto/` and the two `current-low-1080p-*-v20/`
captures retain the optimization's predecessor/reference trail; the clean
snapshot matrix above supersedes them as current reference-adapter evidence;
- all six retail rows record zero pack resources, passes, casters, cascades,
draws, or dispatches. The six unavailable 4K Low/Medium/Auto rows likewise
record zero pack work and pass their strict paired-default framebuffer
comparisons instead of rendering a half-enabled graph;
- `igpu-auto-safe-fallback-v7-paired/` records physical Auto behavior on the
integrated **AMD Radeon(TM) Graphics** adapter (Vulkan 1.4.315, driver
2.0.353). After 180 stable Low samples, Auto failed safe with the exact reason
`GPU p99 19.308 ms (budget 3.000 ms), CPU p99 0.534 ms (budget 0.500 ms),
resident GPU bytes 41648404 (budget 67108864)`. The published state is
`retail/off`, has zero pack resources or work, and retains that visible
reason. Its comparison against the paired time-matched retail artifact
`igpu-retail-current-v6-time-matched/`, using `sky-mask.png`, differs in only
**56 / 1,536,000 compared pixels**, a **0.003645833% (0.00365%) sky-masked
pixel difference**, below the 0.1% gate. This closes physical weak-adapter
safe fallback, not active Low performance on that adapter or the remaining
GPU matrix;
- `compare-retained-transform.json` records the retained-transform image
comparison used by the 9,500-caster steady-state gate.
Stage 1 project-owner gate:
- **PASS — accepted by the project owner on 2026-08-22.** The acceptance closes
Stage 1's physical-display and desktop-performance stop. It authorizes Stage
2; it is not a substitute for Stage 2's connected scenario, long-lifetime,
package lifecycle, additional physical-GPU, or final pack-off/pack-on rows.
Stage 2 machine-local closeout result (2026-08-22):
The exact commands, commits, metrics, artifacts, and evidence limits are in the
[Stage 2 connected and closeout report](../research/2026-08-22-atmospheric-stage2-connected-gate.md).
1. **PASS, except the external second-client row.** The connected graphical
route covered moving local players, known monster encounters
reported under the authoritative `NonPlayerCreatures` category, and equipped
children; landblock publication/demotion; clear, overcast and rain;
outdoor/interior/dungeon transitions; portal travel and reconnect. Capture
the implemented select/disable/re-enable, resize, authored sun/moon source
transitions and weather, and
fresh-process renderer/context/device-recreation assertions against a real
ACE session, proving exact resource convergence and no stale maps/owners.
The implemented two-client gate proved the observer login and movement, but
ACE rejected the concurrent primary login when both used the available
account. The gate now requires dedicated observer environment credentials,
keeps them out of its artifacts, and permits independent character indexes;
a distinct-account connected run must close the authoritative nonzero
`RemotePlayers` row.
2. **EXTERNAL HARDWARE GATE.** Repeat the current clean-source RX 9070 XT
pack-off/Low/Medium/High/Auto × 1080p/1440p/4K × capped/uncapped matrix on
every other supported physical GPU class. The integrated-AMD Auto-to-retail
artifact proves weak-adapter safe fallback only; it does not prove active Low
or the complete matrix on that adapter. Automated weak-GPU fixtures, one
fallback row, and one high-end reference adapter do not prove the remaining
physical rows.
3. **FINAL OWNER GATE.** Complete the visual matrix for Tier-1 neutral values and private-view/UI
isolation; rays at dawn/noon/dusk, behind-camera and occluded states;
foliage cutouts; moving animated shadows under sun and moon; indoor gating;
source-transition continuity; temporal pixelation/shimmer; bias/cascade seam
review; sun-only volumetric weather/occluder behavior; and pack-off
restoration.
4. **PASS.** External install/select/update/remove/fail/recover flows passed in
six fresh connected graphical processes. The nine-stop Medium lifetime route
passed with graceful shutdown, as did the deterministic 12-cycle and
fresh-device convergence fixtures.
5. **FINAL OWNER GATE.** Obtain explicit project-owner acceptance of the final pack-off and pack-on
visual/performance matrix before changing this document to shipped.
### Slice 0 — Contract, capability probe, and acdream-default no-op
**Implementation:** complete. The BCL-only v1 ABI, live revisioned plugin
discovery/catalog, retained Display selection, compatibility/cost summaries,
strict SDK-equivalent entry admission, asynchronous candidate preparation,
frame-boundary activation/withdrawal/fallback, registration-scoped no-retry,
stable diagnostics, no-op sample, and headless exclusion are present and
automated. The checked-in default-path oracle and RX 9070 XT physical pack-off
rows pass; connected lifetime and graphical package evidence now pass. The
remaining supported physical GPU classes stay open.
Define the versioned BCL-only descriptor/registry, manifest fields, semantic
bindings, pack discovery, Display selection, diagnostics, and atomic
activation/fallback transaction. Implement a no-op conformance pack only.
**Acceptance:** `acdream default (retail-faithful)` remains selected on clean and upgraded
installs; pack discovery allocates no GPU resources; the disabled run has the
same pass list, pipeline set, draw/dispatch counts, deterministic framebuffer
digests, and resource ledger as the pre-campaign baseline; malformed,
unsupported, missing, and shader-invalid fixtures all report one precise
reason and render acdream's default path without partial resources or retry loops; headless
hosts load no render assemblies or pack assets.
### Slice 1 — Tier-1 world-colour and filmic stack
**Implementation:** complete. The pack-owned main-world target, bloom chain,
ACES filmic pass, colour grade, vignette, declared neutral settings, resize
recreation, and private-viewport/UI placement are implemented and automated.
The RX 9070 XT 1080p/1440p/4K physical budget rows pass. Project-owner visual
acceptance and the remaining supported physical GPU classes stay open.
Add the main-world intermediate and implement bloom, ACES filmic tonemapping,
colour grade, and vignette through the pack API. Supply half/quarter-resolution
variants and preserve private viewports/UI.
**Acceptance:** every effect can be independently set to its neutral value;
the preset is deterministic across resize/recreate; UI, paperdoll, portal, and
appraisal surfaces retain their accepted colours; 1080p/1440p/4K captures show
no clipping, haloing at the world/UI edge, stale frame, or resource leak; the
slice meets its preset GPU/VRAM budget.
Campaign VM VM3 (2026-08-22) moved the post stack to linear light: every
world/ray/volumetric read is decoded with the 2.2 display assumption; the
bloom intermediate is already linear (decoded once at extraction, so
downstream blur/composite reads need no further decode). Tonemap/grade/
vignette run in linear and the result is re-encoded; the post stack's
neutral settings are a numerical identity (measured on a real frame at
Campaign VM VM3: ≤1 LSB on 99.99 % of pixels; 95 foliage-silhouette pixels
— 58 isolated — differ by up to 73 LSB from cutout-edge rasterization between
two separate client runs, none on any surface).
### Slice 2 — Tier-1 screen-space sun rays
**Implementation:** complete. Authored sun projection, the screen-space
occlusion mask, declared sun/day-group/weather policy, pre-tonemap ray
composition, and deterministic disabled gates are implemented and automated.
Connected time/weather transitions pass; physical-display acceptance for
dawn/noon/dusk, behind-camera, occlusion, and edge-flicker behavior remains in
the final owner visual matrix.
Project the existing authored sun position, build a screen-space occlusion
mask, and composite weather-driven crepuscular rays before tonemapping. This
slice deliberately has no shadow-map dependency.
**Acceptance:** clear dawn/dusk produces visible raking rays, noon makes them
vanish, overcast/rain mutes them, the sun behind the camera or fully occluded
produces none, and camera edges do not streak or flicker. The pack descriptor's
sun-elevation and `activeDayGroup` policy deterministically produces those
states without a second weather/clock owner. The exact same scene with Tier 1
disabled returns to the Slice-0 digest from acdream's default
retail-faithful renderer.
### Slice 3 — Tier-2 moving authored sun-and-moon dynamic shadows
**Implementation:** Stage 1 automated and project-owner live gates complete.
Camera-relative stabilized cascades,
outdoor gating, opaque and alpha-cutout casters, terrain/world receivers,
headline caster membership, exact current animated transforms, bounded
resident replay, GPU-flight ownership, and cached topology/dynamic-transform
refresh were present at the sun-only checkpoint. The approved authored
sun/dominant-moon/secondary-moon resolver and direction-versus-energy handoff
are now present, and focused plus complete fresh-process automated validation
pass. The project owner's 2026-08-22 live round accepted source alignment,
temporal stability, desktop responsiveness, and the final exposure correction.
Per-class diagnostics cover
terrain commands, outdoor
statics, buildings, animated statics, local/remote players, non-player
creatures, other live dynamics, and equipped children without inventing tree
or hostile-monster identity. Fixed-camera morning/afternoon artifacts are not
moon evidence; the live-gate result and its exact boundary are recorded in the
[Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
The machine-local Stage 2 connected and closeout matrix passes. The external
second-client, additional-GPU, and final owner rows above remain open.
This is the campaign's headline slice. Add camera-relative cascades, opaque
and alpha-cutout caster variants, animated SSBO transforms, shadow receivers,
texel stabilization, outdoor gating, and weather/authored-directional-energy
control. Select the exact rendered direction of the visible above-horizon sun,
dominant haloed moon, or secondary moon according to the
[celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md),
while retaining retail's one `DirColor * DirBright` energy channel. Trees,
monsters, players, houses/buildings, terrain, and ordinary outdoor statics
participate through existing scene ownership.
**Acceptance:** in fixed-camera and live dawn/noon/dusk/night captures, shadows
align with and change direction/length under Dereth's authored sun and selected
dominant/secondary moon; overlap and no-source transitions are stable and do
not snap to an unrelated body. Walking players and monsters cast and
self-shadow from their current animated poses; foliage casts leaf/branch
cutouts rather than rectangles; houses and procedural trees retain shadows
through landblock publication/demotion without popping outside the chosen
cascade transition tolerance; indoor/dungeon captures have no outdoor
celestial directional shadow; portal/reconnect/device recreation leaves zero
stale maps or owners. Acne, Peter-panning, cascade seams, distant depth-bias
leaks, temporal pixelation/shimmer, and desktop performance pass the live user
gate and subsequent Stage 2 matrix; the dense-Arwic CPU submission and GPU
budgets pass.
### Slice 4 — Quality scaling and automatic compatibility
**Implementation:** complete. Low/Medium/High declarations, capability and
memory admission, preset cost summaries, retained Display controls, the
built-in Automatic checkbox, hysteretic Auto, diagnostics, asynchronous
off-side candidate preparation, and atomic stable-boundary swaps are present
and automated. The source-identical clean-snapshot RX 9070 XT matrix passes all
30 current rows after the Low dense-pose CPU optimization. The integrated-AMD
physical row proves that persistently over-budget Low returns Auto atomically to
retail with a visible reason and a paired-retail framebuffer match. Active Low
on that adapter and additional supported-adapter matrices remain open.
Land the Low/Medium/High presets, memory ceilings, capability-based preset
availability, stable cascade fitting, resize handling, and optional hysteretic
Auto selection. Auto may change resolution/range only at a stable frame
boundary and must expose its current choice.
**Acceptance:** every supported preset retains all headline caster classes;
weak-hardware fixtures select a valid lower preset or fail safely to acdream's
default renderer;
changing preset cannot leak, stall the render thread, invalidate streaming, or
leave mixed-resolution resources; the quality/performance table is populated
with measured physical-hardware results.
### Slice 5 — Tier-2+ volumetric shafts
**Implementation:** complete for the recorded sun-only scope. The declared
volumetric pass reuses directional-shadow depth only when the selected source
is the authored sun, consumes authored sun/weather/indoor inputs, composites
before tonemapping, and has independent quality/step settings and automated
failure gates. The reference-GPU low-sun 2,048-sample enabled/neutral A/B passes
its incremental cost target. Moon selection does not enable moon shafts.
Connected weather/lifecycle transitions pass; physical-display
occluder/weather acceptance in the final owner matrix and additional physical
GPU classes remain open.
Reuse the directional shadow map for world-space light shafts only while its
source is the authored sun. Drive density, strength, and colour from authored
sun/weather inputs and composite before tonemapping. A selected moon produces
directional shadows but no rays or volumetric shafts.
**Acceptance:** shafts respect terrain, trees, houses, and moving creatures;
clear low sun is strongest, overcast and indoor scenes are muted/off; disabling
shafts leaves Tier-2 shadow output unchanged; the incremental cost stays within
the Tier-2+ budget.
### Slice 6 — Pack SDK and campaign closeout
**Implementation:** SDK deliverables complete and the Stage 1 project-owner
gate passed on 2026-08-22. The v1
manifest schema, semantic binding table, compatibility/failure guide,
validator, built-in pack, and three buildable external samples are present and
automated. Connected graphical package lifecycle and long-run convergence pass.
The distinct-account second-client remote-player row, remaining physical GPU
classes, and final project-owner acceptance remain open; the 30-row RX 9070 XT
reference matrix is complete.
Publish the manifest/schema, semantic binding table, sample no-op pack,
Atmospheric pack, compatibility diagnostics, authoring/validation tool, and
failure-handling guidance. Run the full automated, connected, physical-display,
performance, lifetime, portal, and screenshot matrix.
**Acceptance:** a clean external sample builds without App or Vulkan
references; install/select/update/remove/fail/recover flows work; pack-off
evidence from acdream's default retail-faithful renderer remains authoritative
and unchanged; all resource ledgers
converge after long play, reconnect, portal travel, pack disable, and device
recreation; the project owner accepts the visual matrix before the campaign is
declared shipped.
## Performance budget and measurement
The [measured pre-campaign baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#performance-baseline-and-the-binding-constraint)
is **519.7 FPS with CPU/GPU p50 of 1.869/1.096 ms**, and dense towns are
CPU-submission-bound. Fullscreen work may occupy currently idle GPU time, but
it is not treated as free. Shadow cascades must protect the CPU submission
path.
| Preset | Incremental GPU p50 / p99 at 1080p | Incremental render-CPU p50 / p99 | Pack-owned resident GPU memory **at 1080p** (scales with pixel count — `RenderPackResidentBudget.Effective`, #425) |
|---|---:|---:|---:|
| Low | ≤ 2.0 / 3.0 ms | ≤ 0.15 / 0.50 ms | ≤ 64 MiB |
| Medium | ≤ 3.25 / 4.50 ms | ≤ 0.25 / 0.75 ms | ≤ 128 MiB |
| High | ≤ 4.50 / 6.00 ms | ≤ 0.35 / 1.00 ms | ≤ 256 MiB |
The current physical reference matrix is
`artifacts/atmospheric-rendering/matrix-clean-snapshot-dense-linear-v20/` on an
AMD Radeon RX 9070 XT (Vulkan 1.4.349, driver 2.0.395). The source-identical
isolated commit and Release binary both identify `4876c970`; tracked source
status is empty. All 30 rows pass. At capped 1080p, the exact 2,048-sample
windows are:
| Selection | Incremental render-CPU p50 / p99 | Inclusive GPU p50 / p99 | Resident pack memory |
|---|---:|---:|---:|
| Low | 0.108 / 0.164 ms | 0.900 / 1.002 ms | 39.719 MiB |
| Medium | 0.116 / 0.144 ms | 1.001 / 1.023 ms | 73.590 MiB |
| High | 0.121 / 0.145 ms | 1.240 / 1.273 ms | 113.556 MiB |
| Auto (settled High) | 0.122 / 0.146 ms | 1.211 / 1.222 ms | 113.556 MiB |
Every uncapped active row also passes. Across capped and uncapped active rows,
the maximum measured p50/p99 and resident memory are: Low 0.108/0.164 ms CPU,
1.070/1.080 ms GPU, 60.868 MiB; Medium 0.117/0.194 ms CPU, 1.152/1.163 ms GPU,
103.583 MiB; High 0.121/0.159 ms CPU, 2.237/2.252 ms GPU, 238.141 MiB; and Auto
0.122/0.166 ms CPU, 1.482/1.494 ms GPU, 145.856 MiB. All six retail rows record
zero pack resources/work. At 4K, Low needs 131,302,400 bytes and Medium/initial
Auto need 198,440,960 bytes, so those six capped/uncapped rows correctly report
`ResourceUnavailable`, create no pack resources/work, and pass their strict
paired-default framebuffer comparisons. This closes the current reference
adapter, not the connected receiver A/B route or other supported/weak physical
GPU classes.
Tier 2+'s separate low-sun physical A/B is recorded in
`artifacts/atmospheric-rendering/volumetric-performance-ab-1080p.json`. Both
runs pin High, 1080p uncapped, clear weather, 16.667° sun elevation, the same
50 m / 180° / 10° camera, 9,498 casters, four cascades, and exact 2,048-sample
windows. Enabling one volumetric draw over the neutral-strength run adds
**0.189 ms GPU p50 / 0.219 ms p99**, **0.009 ms CPU p50 / 0.007 ms p99**, and
4,147,200 resident bytes. The measured GPU p50 passes the Tier-2+ ≤0.40 ms
reference target; connected weather/occluder behavior and other adapters still
require their own rows.
acdream's default path has a stricter gate: zero new enhancement passes,
images, buffers, submissions, or shader variants, with CPU/GPU deltas within
the existing run-to-run noise envelope and deterministic reference captures
unchanged. `NoOpRenderPackProductionIntegrationTests` pins the pass list,
pipeline set, draw/dispatch tuple, framebuffer SHA-256, and resource ledger of a
**2x2 synthetic, one-draw composition fixture on a recording device that does
not rasterize** — it proves the controller arm adds nothing to that shape, not
that the production frame is unchanged. The matrix's six physical retail rows
record zero pack work at all three resolutions and both pacing modes. The
actual pack-off pixel and production-performance invariance against the
pre-campaign build `6c79d35c` was established later by Campaign VM slice VM0
([report](../research/2026-08-22-vm0-default-path-invariance.md), corrected
2026-08-22).
Measurement protocol:
- Use existing asynchronous GPU timestamps and frame diagnostics. Never add a
`glFinish`/device-idle-style measurement fence to the frame loop.
- Runtime Auto compares the declared incremental CPU budget with pack-added
target-preparation, shadow, post, and volumetric recording only. The complete
enhanced main-world receiver recording is retained separately as an absolute
CPU diagnostic; it is not itself an incremental delta. GPU accounting remains
conservatively inclusive of the complete enhanced receiver pass and every
resolved pack pass, exactly once after asynchronous resolution. Identical
pack-off/on runs remain the authority for the final receiver CPU delta and
the complete physical incremental A/B result.
- Run capped and uncapped Release builds; record CPU/GPU p50, p95, and p99,
FPS, draw/dispatch submissions, shadow-caster count, cascade draw count,
transient/retained GPU bytes, and process working/private memory.
- Compare pack off, Low, Medium, and High with identical camera paths, render
resolution, active day group, authored celestial/time keyframe, entity set,
and warmed residency.
- Cover pinned dense Arwic, a foliage-heavy outdoor route, a building cluster,
moving-player/monster combat, dawn/noon/dusk/night plus sun/moon/no-source
transitions, clear/overcast/rain, a dungeon, portal travel, resize, reconnect,
and a long lifetime run.
- Measure 1920x1080, 2560x1440, and 3840x2160 on each supported physical GPU
class. Report—not hide—unavailable presets.
- No cascade may rerun CPU PView/portal traversal or issue per-object draws.
The pass records CPU classification calls and submission counts so this is
an enforced gate, not an architectural hope.
- Pipeline creation and pack validation occur before atomic activation. Normal
play may not hitch on first shadow, weather, caster, or quality use.
## Quality scaling for weak hardware
These are starting envelopes to validate, not asset or world guarantees.
Distances are metres and are always clamped to current resident world data.
| Setting | Bloom/rays | Directional shadows | Volumetric shafts | Approx. depth-map memory at 32-bit depth |
|---|---|---|---|---:|
| Off / acdream default | Off | Off | Off | 0 MiB |
| Low | Quarter resolution | 2 × 768² cascades, about 72 m maximum reach | Off by default | 4.5 MiB |
| Medium | Half resolution | 3 × 1536² cascades, about 144 m maximum reach | Quarter resolution | 27 MiB |
| High | Half/full as measured | 4 × 2048² cascades, about 240 m maximum reach | Half resolution | 64 MiB |
Additional scaling rules:
- Prefer reducing cascade count, shadow resolution, reach, bloom/ray
resolution, and sample count before removing a feature's semantic
correctness.
- Preserve alpha-tested foliage and animated transforms at every shadow
quality. A cheaper preset may look softer or end sooner; it may not turn a
tree into a rectangle or freeze a monster's shadow.
- Clamp resource dimensions and bytes before allocation. A capability probe
that cannot support Low disables the pack and explains why.
- Preset availability uses the selected Vulkan adapter's probed 2-D image and
array-layer limits. Optional pack memory receives at most one eighth of its
device-local heap, capped at 256 MiB resident and 512 MiB transient; Auto
starts at Low when Medium is unavailable and acdream's default remains the fallback if
Low cannot fit.
- Optional Auto quality uses long hysteresis and stable frame-boundary swaps;
it never oscillates cascade layouts frame to frame. If Low stays over its
declared runtime GPU/CPU/resident budgets for 180 stable samples, Auto
retires the complete pack and returns to acdream's default with the measured
and declared limits in the visible failure reason.
- 4K defaults may choose lower post-process resolution because Tier 1 pays
approximately four times the 1080p pixel workload.
## Constraints and traps
This list carries forward every item in the findings' measured
[shadow-specific constraints](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#shadow-specific-constraints)
and adds the current renderer's ownership and lifecycle boundaries.
1. **The renderer is the shipped pass-based Vulkan RHI.** Design against
`IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder` and explicit pass/pipeline
descriptions. Do not revive an OpenGL backend or build a parallel renderer.
2. **The current PView graph is authoritative.** Shadow and atmosphere passes
consume its retained scene; they do not introduce a competing visibility
owner or change punch/seal, shared-alpha, particle, or private-viewport
ordering.
3. **CPU submission is the limiting dimension.** Reusing the full bounded
resident caster set is preferable to CPU-reculling it per cascade. GPU
culling is the only planned escalation.
4. **Alpha-tested foliage needs sampling and discard.** Reusing the existing
empty `portal_depth` fragment shader would cast solid tree rectangles.
5. **Animated casters use the existing N.5 SSBO transforms.** A second pose,
animation tick, or entity owner is forbidden.
6. **Indoors has no outdoor celestial directional shadow.** Dungeon/EnvCell
authored ambient and local lighting wins; outdoor sun/moon directional
shadows, sun rays, and sun shafts are gated off.
7. **Cascades are camera-relative and streaming-bounded.** They may not use a
fixed Dereth-wide extent, request landblocks, retain retired generations, or
draw stale portal destinations.
8. **Depth bias is specified in meaningful eye/world units.** A constant NDC
bias spans approximately `b*d²/near` metres of eye depth at distance and can
recreate issue #129's door-shaped holes through hills. Bias, normal offset,
cascade projection, near/far fitting, and reversed-depth conventions must be
tested together at near and far ranges.
9. **“Shadow” is an overloaded project term.** Existing `shadow_objects` and
`CPhysicsObj::add_shadows_to_cells` are collision registration, not light
shadows. New names use `DirectionalShadowMap`, `ShadowCaster`, or
`CelestialDirectionalShadow`; never generic `ShadowObject`.
10. **Authored celestial position, directional energy, and weather are
inputs.** Do not invent another celestial clock, light-energy channel,
weather state, or hard-coded dawn/noon schedule. The directional map uses
the visible above-horizon sun/dominant moon/secondary moon's exact rendered
direction but retail's single interpolated `DirColor * DirBright` colour/
energy channel, per the
[celestial source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
Rays and shafts remain sun-only and use the pack's declared sun-elevation
curve and categorical `activeDayGroup` mapping. The decomp evidence proves
the category reaches the frame, not an authored numeric ray intensity, so
the enhancement mapping must remain explicit pack policy.
11. **Atmosphere ordering is deliberate.** Rays/shafts composite before
tonemapping; retained UI and private viewports remain outside main-world
post-processing.
12. **Transparency remains ordered.** The pack cannot flatten the retail
world-alpha queue into an unordered shadow/post pass. Truly translucent
surfaces cast no opaque shadow until separately designed.
13. **Generation and GPU-flight lifetimes remain exact.** Pack images,
descriptors, and pipelines retire through existing fences and converge on
disable, resize, portal, reconnect, reset, failure, and device recreation.
14. **4K is a distinct performance row.** Tier-1 effects scale with pixels;
passing at 1080p is not evidence for 4K.
15. **Do not repeat closed investigations.** High-res DAT precedence is not
dropping overrides, AC detail textures are colour/alpha rather than normal
maps, and the engine's historical DOT3 capability does not turn those
assets into PBR inputs; these points are already falsified in the findings.
16. **Caster evidence must not exceed source identity.** Diagnostics separately
count terrain commands, outdoor statics, buildings, animated statics,
local/remote players, non-player creatures, other live dynamics, and
equipped children. Outdoor statics include trees but have no authoritative
tree discriminator; non-player creatures include monsters but have no
render-only hostile-monster-versus-NPC discriminator. Visual/connected
acceptance must name those limits instead of fabricating narrower counts.
## What this does NOT do
- It does **not** change acdream's default retail-faithful rendering path, its
expected output, or its authority in fidelity tests.
- The Atmospheric shader pack does **not** own or depend on #226 detail
texturing or the terrain-normal parity correction. Those remain separate
Track A ports even though the project owner authorized their implementation
in the same worktree.
- It does **not** add PBR or fabricate normal, roughness, metalness, or material
maps that AC's assets do not contain.
- It does **not** change terrain vertices, collision triangles, walkability,
slope response, physics shadow lists, movement, projectiles, or any Runtime
physics/collision owner.
- It does **not** change gameplay rules/state, network messages or ordering, or
any Runtime gameplay/network owner.
- It does **not** extend view distance, streaming radius, landblock residency,
or PView visibility to find more shadow casters.
- It does **not** add an indoor sun or replace authored EnvCell/local lights.
- It does **not** turn moon texture brightness or mesh luminosity into another
world-light energy channel, and it does not produce moon rays or moon shafts.
- It does **not** post-process retained UI or silently restyle private
paperdoll, appraisal, or portal viewports.
- It does **not** promise that every pack or quality preset runs on unsupported
hardware; compatibility failure is explicit and safely returns to acdream's default renderer.
- Campaign AR does **not** outrank active M4 gameplay work. The project owner's
explicit reprioritization authorizes this campaign without changing M4's
milestone priority; #268 + TS-8 are already complete and retired.
## Completion gate
The requirement ledger and the exact remaining external rows are consolidated
in the [Campaign AR completion audit](../research/2026-08-22-atmospheric-campaign-completion-audit.md).
The design, implementation, Stage 1 owner gate, and every available
machine-local Stage 2 connected, performance, lifetime, package, shader, build,
and complete-test gate are complete. The campaign becomes **shipped** only
after the distinct-account second-client remote-player row, remaining physical-
hardware rows, and final visual gates above pass and the project owner accepts
both sides of the final matrix:
- **pack off:** unchanged output, performance, ownership, and lifecycle from
acdream's authoritative default retail-faithful renderer; and
- **pack on:** moving authored sun-and-moon directional shadows from trees,
monsters, players, and buildings; sun-only rays/shafts; scalable atmosphere,
safe compatibility fallback, measured budgets, and clean long-lived resource
convergence.

View file

@ -1,199 +0,0 @@
# Campaign CT — complete chat parity (system + GUI)
**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated.
CT-B4 landed 2026-08-21 after the research block turned out to rest on a wrong
premise (see the slice). One item deliberately not shipped: CT-B3 (word
filtering — dropped by user direction, register row CT-2).
**Carried forward:** ~~multi-frame state media~~ **DONE 2026-08-21.** The
importer now keeps the whole authored sequence and `UiMediaSequence` plays it.
Measuring the real data (`LayoutDump --media 0x1000048C`) corrected the
behaviour as well as enabling it: the indicator blinks three times over three
seconds and then hands off to `Ghosted`, hiding itself. Retail's is a transient
attention-flash, not a badge that stays lit until you scroll down. Register
rows CT-3, CT-4.
**Goal, set by the user 2026-08-21: complete retail parity for the chat
system AND the chat GUI.** Not "fix the green name" — that was the symptom
that started the review. The bar is that a retail player sitting down in front
of acdream's chat window finds nothing missing and nothing behaving
differently.
Campaign CH (2026-08-09, closed user-accepted) landed colours, side channels,
the 152-verb command registry, the window shell and verbatim `/help`. CT is
the pass that closes what CH did not reach.
Research notes (all 2026-08-21): `chat-texttag-model.md`,
`chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`,
`retail-chat-window-ui.md`, `acdream-text-stack-audit.md`,
`acdream-chat-ui-audit.md`.
## Definition of done
1. Every retail chat behaviour is either implemented, or has a divergence-
register row saying why not.
2. Every user-visible chat surface has a test that would catch its regression.
3. The chat digest and `docs/ISSUES.md` describe reality (both are stale today).
## What the review established
### The green clickable name is a TEXT-STACK gap, not a chat gap
The client sprintfs literal markup into the line —
`<Tell:IIDString:{iid}:{name}>{name}<\Tell> says, "{text}"`, closing marker a
literal backslash — and `UIElement_Text::InqGlyphs @0x00468EA0` parses the
brackets while appending, calling `TextTagFactory::MakeTag @0x00478480`. Tags
attach **per glyph**; a "run" is emergent (adjacent glyphs with equal tag
pointers). A glyph takes the tag colour (property `0x1D`) only when a tag is
open AND its `m_type == 0x10000001`, else the line colour (`0x1B`). Only
senders with a GUID in `0x50000001..0x6FFFFFFF` are tagged.
Colour **measured** from the installed dats (`LayoutDump --colors`), chat
`0x2100006F` / transcript `0x10000011`: `P0x1B` = RGB(204,204,204),
`P0x1D` = **RGB(0,178,0)**. The tag colour is per-ELEMENT and authored, while
the line colour on that same element comes from the runtime chat table —
filing "tag green" into the LogTextType table would put it in the wrong place.
Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY
@0x004688F0` → `GlyphList::InqGlyph @0x00473430` → virtual `HandleClick` at
tag-vtable `+0x14` → `gmMainChatUI::RecvNotice_TextTag_IIDStringClick
@0x004CCE10` → `ChatInterface::StartTell @0x004F41F0`, which writes
`"@tell {Name}, "`, takes focus, and shows the entry bar. Clicking a name
always opens a TELL — fellowship, allegiance, patron/vassal and named-channel
lines all embed the same markup. No hover effect.
### Our side is closer than feared
`UiText` **already** draws multi-coloured runs (`TextRun`/`RunsProvider`, used
by the character stat panel); it is gated to `OneLine == true`. The draw path
needs no renderer work — arbitrary pen X, substring measurement — and
`UiText.HitChar` already resolves a click to (line, column). The blocker is
that `ChatVM.RecentLinesDetailed()` drops `Sender`/`SenderGuid` one step before
the renderer, though `ChatEntry` carries them the whole way.
### The command registry is already at parity
All 13 verbs the CH3 research note lists as MISSING were closed by CH4 and
verified present 2026-08-21 (`cg`, `soc`, `o`, `co-vassals`, `fellows`,
`group`, `party`, `vassal`, `ab`, `guild`, `ct`, `clfg`, `crp`). `/g` correctly
resolves to Fellowship, confirmed against the live retail client. That note's
"acdream status" columns are stale and now carry a correction banner.
## Slices
### Group A — the tagged-text capability (strict chain, A1→A5)
Nothing is user-visible until A4.
- **CT-A1** Multi-line text elements carry coloured runs. Additive; the ~50
files using `Line` are untouched. No behaviour change.
- **CT-A2** Parse the tag markup into runs with a tag payload, including
retail's rule that an unparseable bracket closes the open tag. Pure, unit-
testable, no UI.
- **CT-A3** Stop flattening: carry sender name + guid through `ChatVM` into
spans, and compose retail's markup in the speech handlers behind the
player-GUID-range gate.
- **CT-A4** Apply the authored `0x1D` tag colour when a tag is open and its
type matches. **Names turn green.**
- **CT-A5** Sub-line hit-testing and `StartTell`. **Names become clickable.**
### Group B — chat SYSTEM behaviours
- **CT-B1** Bound the transcript: 10,000 chars, trim to ~7,500 preferring a
newline boundary (`TruncateChatLog @0x004F4290`). Today it grows for the life
of the session — a slow leak, not only a fidelity gap.
- **CT-B2** Text-replacement macros: typing `/r `, `/t `, `/tell ` rewrites the
input to `@tell {LastTeller}, ` on the space keypress
(`HandleTextReplacements @0x004F50D0`). The commands already work; the
visible expansion does not exist.
- **CT-B3** ~~`FilterLanguage` word filtering~~ — **DROPPED by user direction
(2026-08-21): "I do not want any censoring."** acdream keeps the option
itself, which still stores and ships its bit to the server exactly as retail
does, but performs no client-side substitution. Registered as CT-2 rather
than left as an implicit gap, since it IS a knowing departure from retail
(`PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW
@0x00682A30` inside `AddTextToScroll`).
Worth keeping on record, because the attempt established two things that
would otherwise be rediscovered if this is ever revisited:
1. The table's dat id is not readable from the decomp — `CheckCensorsW`
reaches it through `DBObj::GetByEnum` with the arguments elided by Binary
Ninja. The portal master enum map (`0x25000000`) has 22 categories, with
category 3 (`0x0E010001`, `0x0E010002`) and the single-entry categories 8
and 11 the plausible candidates.
2. Chorizite.DatReaderWriter declares a `TabooTable` type but does NOT decode
it — only `DBObjType` and `HeaderFlags`. The format would have to be
decoded here first.
And the matching is an algorithm, not a word list:
`TabooTable::CreateCheckString @0x00681570` normalises a candidate before
`StringMatchesFilter @0x00681600` compares it, which is how retail catches
obfuscated spellings.
- **CT-B4** ~~The plain-text session chat log~~ **DONE — and the premise was
wrong.** There is no automatic session log to have a path for. Retail's
`@log` is a COMMAND: `ClientCommunicationSystem::DoSetOutput @0x0057E4F0`
takes a filename, `StartCopyOutputToFile @0x0057C8A0` does the
`fopen(name, "a+")`, and running it again with no argument closes it. So
"path and rotation UNKNOWN" was asking a question the design does not have:
the player names the file, and there is no rotation because it appends
forever.
The path question that DOES exist — where a bare name lands — is answered by
retail's own help text, which the CH4 help table already carried verbatim
without anyone reading it: "a log file named Aclog.txt **in your Asheron's
Call directory**". acdream cannot use the install directory (the launcher
replaces it atomically on update), so a bare name lands in the client's own
log directory. Rooted paths are honoured verbatim. Register row CT-5.
Landed with it: the verb registered in the catalog (it had a help entry
since CH4 but no catalog entry, so `/log` printed help and did nothing),
retail's `.txt`-for-extensionless rule, all five reply strings byte-decoded
from the paired binary, and the writer attached at OPEN so only text after
the command is copied. The line logged is the composed display line with the
shared timestamp, because retail's `fprintf` sits inside `AddTextToScroll`
— downstream of composition, upstream of glyph layout.
### Group C — chat GUI
- **CT-C1** Auto-scroll vs unread: retail samples "was at bottom" BEFORE the
line lands; if you had scrolled up it leaves you there and lights the unread
indicator (`0x1000048C`), which scrolls to bottom and clears on click.
- **CT-C2** Escape in the chat input is a complete no-op — `UiField` has no
`Escape` case, and a focused field also suppresses the input dispatcher's
fallback, so there is no clear, no defocus and no hotkey passthrough.
- **CT-C3** Option-gated timestamp prefix (`%#H:%M:%S `, colour index `0x0C`,
grey), gated on `PlayerModule::DisplayTimeStamps()`.
- **CT-C4** Input-bar editing parity: clipboard and selection paths
(Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the
floating-window Close button have zero coverage. **DEFERRED** — pure test
coverage over behaviour the audit confirmed already works, so it changes
nothing a user can see. Worth doing; not worth blocking the campaign on.
### Group D — hygiene
- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never
constructed in `src/`, then removed with its three panel-only test files.
`ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM`
coverage; only the one `ChatPanel` render test went.
- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.**
`docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are
recorded CLOSED there. Only the chat digest's "Open" section was stale, and
it is corrected: genuinely open are #359, #360, #361, #366. The digest also
gained a Campaign CT section and three new DO-NOT-RETRY rows.
## Research still owed before the affected slices
- The tag-type roster behind `m_type == 0x10000001` — only "Tell" is
confirmed; the full set lives in the DAT `EnumMapper` category `0x18`.
Blocks nothing in Group A, but decides whether other tag shapes exist.
- Whether retail's transcript supports text selection distinctly from the
entry field (blocks CT-C4's scope).
- Whether a chat-specific sound cue exists — a grep came back empty, which is
weak evidence, not proof of absence.
## Deliberately NOT in scope
Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have
no listener in the retail build we target, so porting them would be inventing
behaviour. CT-A5's dispatch is generic, so they cost nothing to add later.

View file

@ -1,200 +0,0 @@
# Campaign QT — the contract tracker (H.3's client half)
**Status:** CLOSED USER-ACCEPTED 2026-08-21. All six slices landed and the
connected gate passed, together with Campaign QJ's two tabs.
**Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete
the quest." Everything in that sentence works today EXCEPT the player's ability
to see what they have accepted. NPC dialogue, emote text, soul emotes, tells and
the quest-failure strings all render; Campaign CT (closed 2026-08-21) added the
`<Tell:IIDString:…>` markup those dialog lines carry. What is missing is the
only STRUCTURED view of quest state a retail client ever gets.
**What H.3 is not.** The roadmap line reads "122 EmoteType × 39 Trigger
mini-VM", which describes the SERVER's job. Per `r10-quest-dialogs.md` §1.3 the
retail client never stores a quest flag, never evaluates an emote, and is never
told a flag changed. It learns about quests three ways: dialog strings the
server already formatted, generic error toasts, and the contract tracker. Two of
the three ship. So H.3's remaining client scope is this campaign, and the emote
VM is explicitly out of it.
## Measured ground truth
### The panel
`LayoutDump --find 0x1000004B` (the `UIElement::RegisterElementClass` id from
`gmContractsUI::Register @0x00499C80` — registration keys on the element's
**Type**, not its id) finds the class in six layouts. `0x21000069` holds it as a
standalone 300x500 root (`0x100005CD`); the rest embed it at 300x575 inside
window chrome.
Authored children of `0x100005CD`:
| Element | Type | Rect | Reading |
|---|---|---|---|
| `0x100005CE` | 1 | 8,8 80x18 | header button |
| `0x100005D6` | 1 | 160,8 80x18 | header button |
| `0x100005CF` | 5 | 8,30 270x298 | the contract list |
| `0x100005D0` | 11 | 278,30 16x298 | its scrollbar |
| `0x100005D8`/`0x100005DF` | 12 | y=332 | label / value |
| `0x100005D9`/`0x100005E0` | 12 | y=352 | label / value |
| `0x100005DA`/`0x100005E1` | 12 | y=372 | label / value |
| `0x100005DB`/`0x100005E2` | 12 | y=392 | label / value |
| `0x100005DE` | 12 | 8,418 270x52 | description block |
| `0x100005DD`, `0x100005E3`, `0x100005DC` | 12/12/1 | y=468 | button row |
### The wire
Both opcodes are already NAMED in `GameEventType.cs` and nothing parses them —
the bytes arrive and are dropped.
`0x0315 SendClientContractTracker` — one tracker plus two flags:
```
uint32 Version
uint32 ContractId
uint32 Stage
double TimeWhenDone
double TimeWhenRepeats
uint32 DeleteContract (bool widened)
uint32 SetAsDisplayContract (bool widened)
```
`0x0314 SendClientContractTrackerTable` — a full replacement, as a packable
hash table (`PackableHashTable<unsigned long, CContractTracker>` in the decomp
at `0x00497C10`): the familiar `u16 count` / `u16 numBuckets` header, then
`u32 key` + the 28-byte tracker per entry. NO trailing flags on this path.
Source: `ContractTrackerExtensions.Write`, `GameEventSendClientContractTracker`,
`ContractManager.Write` in ACE; cross-checked against the retail decomp's own
`PackableHashTable<unsigned long,CContractTracker>` instantiations.
`ContractStage`: `1` Available, `2` InProgress, `3` DoneOrPendingRepeat,
`4 + n` ProgressCounter with n steps done.
### `gmContractsUI::FillProgressString @0x00498DE0` — the one real algorithm
Recovered whole. The x87 compares are the standard `fcom` + `sahf` pattern;
`(status & 0x41) != 0` tests C0|C3, i.e. **<= 0**.
```
stage 1 -> "Available"
stage 2 -> "In Progress"
stage 3:
if TimeWhenRepeats <= 0
-> QuestflagRepeatTime empty ? "Done" : "Available"
remaining = TimeWhenRepeats - (now - timeOfServerUpdate)
if remaining <= 0 -> "Available"
else -> "Done (" + DeltaTimeToString(remaining) + " to Repeat)"
stage >= 4:
if DescriptionProgress empty -> "In Progress"
else -> sprintf(DescriptionProgress, stage - 4)
```
Three things a reimplementation would get wrong:
1. **`TimeWhenDone` is never read.** Only `TimeWhenRepeats` drives the text.
2. **`timeOfServerUpdate` is not on the wire.** The client stamps arrival and
counts down from its own clock, so the countdown has to be anchored at parse
time, not recomputed from the server value each frame.
3. **`DescriptionProgress` is a printf format** taking one integer, `stage - 4`.
It is not a literal string.
### It is not a "contract panel" — it is tab 1 of the JOURNAL panel
Measured from the installed dats. Host `0x2100006E`, `gmPanelUI` slot
`0x10000559`, whose own authored `0x10000029` is **`0x19` = 25** — the same
slot-key recipe `RetailPanelCatalog` already uses for Options (10), the social
panel (12) and Map/House (16). Three tabs:
| Tab | Caption | Page | Page type |
|---|---|---|---|
| `0x100005D3` | **Contracts** | `0x100005D4` | `0x1000004B` = `gmContractsUI` |
| `0x10000560` | **Journal** | `0x10000563` | `0x10000048` — notes: "Title:", "Notes:", "First" |
| `0x10000561` | **Page List** | — | — |
`0x10000562` (type 1, at 276,0) is the panel's own corner button.
Only the Contracts tab is in scope. The Journal notes page and Page List are
their own feature and are NOT part of Campaign QT — mounting the panel with two
dead tabs is the expected intermediate state, not a defect.
### The contracts page, resolved
Authored text read out of the dats (`LayoutDump --props`, which now resolves
`StringInfo` rather than printing the type name):
| Element | Role |
|---|---|
| `0x100005CE` / `0x100005D6` | list column headers — "Contract" / "Status" |
| `0x100005CF` (type 5) | the list, scrollbar `0x100005D0` via property `0x72` |
| `0x100005D1` / `0x100005D2` | per-ROW children: contract name / progress text |
| `0x100005D8``0x100005DF` | "Status:" → value |
| `0x100005D9``0x100005E0` | "Contact:" → value |
| `0x100005DA``0x100005E1` | "Contact Location:" → value |
| `0x100005DB``0x100005E2` | "Quest Location:" → value |
| `0x100005DE` | description block (270x52, wrapping) |
| `0x100005DD``0x100005E3` | "Timed:" → value |
| `0x100005DC` | "Abandon" button |
`gmContractsUI::RefreshContractListbox @0x00499830` walks the tracker list and,
per row, sets `0x100005D1` from the contract's name and `0x100005D2` from
`FillProgressString`, caching the result back into the row. The list is a
`UiTemplateListBox` here — the same widget OP2 built for the Options panel — so
the page is binding rather than new widget work.
## Slices
- **QT1 — wire.** Typed records + parsers for `0x0314`/`0x0315`, arrival stamp
included. Pure; no UI, no state ownership.
- **QT2 — dat.** Read `ContractTable`/`Contract` (name, description, progress
description, NPC names, the three positions). Nothing reads it today; the
only reference in the tree counts them in a CLI diagnostic.
- **QT3 — state.** `RuntimeContractState` as a session-scoped J4-style owner:
full replace, single add/update, delete, and the display-contract selection.
Clears at generation reset.
- **QT4 — the progress string.** Port `FillProgressString` + the retail
`DeltaTimeToString` it calls. Table-driven tests over every stage arm.
- **QT5 — the panel.** Register slot 25 in `RetailPanelCatalog`, mount the
Journal panel by the OP3/FA recipe, and bind the Contracts page: rows from
`IRuntimeContractView` x `ContractCatalog`, progress from QT4, selection
driving the detail pane. The other two tabs mount empty.
- **QT6 — open/close.** The open path (no toolbar button authors slot 25, so
it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the
plugin-visible read surface from `r10-quest-dialogs.md` §11.6.
### Landed
QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310`
(catalog + progress string), QT5/QT6 (the panel and its open path).
**The open path needed no new keybind.** Toolbar button `0x1000055A` authors
`0x10000029 = 0x19` and has been in `ToolbarController.PanelButtonIds` since
the toolbar was ported — it simply had no panel registered behind it, so
clicking it did nothing. Registering slot 25 completed a wiring that was
already three-quarters present.
**The plugin surface** (`r10-quest-dialogs.md` §11.6's contract half) ships as
`IGameState.Contracts`, projected through `ContractPluginProjection` — a
pull-through view of the canonical tracker, never a mirror. Both hosts
implement it; the headless one carries the numeric fields without the authored
text, since a bot has no dat access. The rest of §11.6 (chat stream, tells,
give, use, confirmations) is other features and stays out of Campaign QT.
### Owed
- The connected user gate: accept a quest against live ACE, open the Journal
panel, confirm the list, the progress column and a repeat countdown.
- ~~The Abandon button is deliberately unwired~~ **WIRED 2026-08-21.** The
claim that it had no wire message was wrong: it is game action `0x0316`
carrying one contract id, and ACE answers with the `0x0315` delete QT3
already handles. Nothing is removed locally, so a refused abandon leaves the
quest visibly intact.
- The Journal notes page and Page List tabs mount inert, by design.
## Definition of done
1. Accepting a quest against live ACE shows it in the panel; completing it
updates the stage; a repeatable one shows its countdown.
2. Every ported algorithm cites its retail address.
3. Every slice has a test that would catch its regression.

View file

@ -1,121 +0,0 @@
# Campaign QJ — the Journal and Page List tabs
**Status:** CLOSED USER-ACCEPTED 2026-08-21. All five slices landed and the
connected gate passed.
The gate took four rounds, and every defect it found was the same mistake in a
different place — an element bound as the wrong thing, or a binding never
tested:
1. **Button property `0x0D`** read as "starts disabled", which killed every
button on the panel (register QJ-2).
2. **The location readout** is authored EDITABLE, so it is a `UiField`; bound
as `UiText` it silently discarded every write — the value reached the model
and the file and never the screen.
3. **Handlers deferred their redraw** to the next frame's `Tick` where retail
redraws at the click.
4. **The timer's unit labels** stayed visible behind the running readout,
because retail's `ShowEditableTimer` toggles each box AND its label.
Round 3's "Record does nothing" turned out not to be a defect at all: the
character was indoors, where retail's own `gid_to_lcoord` fails and nothing is
recorded. Faithful, and now commented so it does not read as a gap.
The durable outcome is `JournalPanelLiveBindTests` — see
`claude-memory/feedback_test_the_binding_seam.md`. Completes the panel Campaign QT mounted: QT
shipped the Contracts tab and left the other two inert by design.
**Scope:** retail's `gmJournalUI` (element type `0x10000048`, page
`0x10000563`) and `gmPageListUI` (type `0x10000049`, page `0x10000564`).
## What this actually is
A **per-character notebook**, entirely client-side. No wire, no server
involvement, no dat content — the player writes the pages. Each page carries a
label, a title, free-form notes, a recorded LOCATION, and a countdown TIMER.
The Page List tab is a searchable index over those pages.
Nothing about it depends on quests; it shares the panel with Contracts and
nothing else. That it is called "Journal" while the panel is also called
"Journal" is retail's own naming, not a mistake here.
## Measured ground truth
### The file format
`gmJournalUI::SavePages @0x00497270` / `LoadPages @0x00496AC0`. A plain tagged
text file, `fopen` mode `w+`. Both call sites pass the literal prefix
`"Journal"`; the path template is `%s%s-%s-%s.txt`, i.e.
`{dir}Journal-{server}-{character}.txt`.
```
<NEWP> begins a page (a file that does not open with one is refused)
<PNUM> %d page number
<LABE> %s label (authored max length 16)
<TITL> %s title (32)
<NOTE> %s notes (2048)
<DAYS> %d timer days
<HOUR> %d timer hours
<MINU> %d timer minutes
<LOCX> %f recorded location
<LOCY> %f
<TIME> %f running-timer value
```
Retail's own load error, byte-decoded: `"Problem loading journal: Your journal
file does not create a new page!"`
### The Journal page (`0x10000563`)
| Element | Role |
|---|---|
| `0x10000567` | "New" button |
| `0x10000569` | label edit box (`0x1E` = 16) |
| `0x1000056A` / `0x1000056B` | "Title:" / title edit box (32) |
| `0x1000056C` / `0x1000056D` | "Notes:" / notes edit box (2048), scrollbar `0x1000056E` |
| `0x1000056F` / `0x10000570` / `0x10000571` | "First" / "~ 1 ~" / "Last" |
| `0x10000572` / `0x10000573` / `0x10000574` | "Location:" / "00.0S, 00.0W" / "Record" |
| `0x10000575` | "Timer:" |
| `0x10000576` `0x10000577` | days field, "d" |
| `0x10000578` `0x10000579` | hours field, "h" |
| `0x1000057A` `0x1000057B` | minutes field, "m" |
| `0x1000057C` | running-timer text — OVERLAPS the three fields at x=84 |
| `0x1000057D` | "Start" button |
| `0x10000566` | bottom-right button (65x32) |
`0x1000057C` sharing x=84 with the day/hour/minute fields is the authored form
of `ShowEditableTimer @0x00495770` vs `ShowRunningTimer`: the same strip is
either three editable numbers or one running readout, never both.
### The Page List page (`0x10000564`)
| Element | Role |
|---|---|
| `0x1000057F` `0x10000580` `0x10000581` `0x10000582` | headers "#" / "Title" / "Timer" / "Label" |
| `0x10000583` | the list, scrollbar `0x10000584` |
| `0x10000585` | "Delete" |
| `0x10000586` / `0x10000587` / `0x10000588` | "Search:" / search box / "Reset" |
`gmPageListUI::PageContainsString @0x00493B60` is the search predicate;
`CheckForDoubleClick @0x00493140` opens the page
(`gmJournalUI::GotoPage @0x00496430`).
## Slices
- **QJ1 — the page model and its file.** `JournalPage` plus a faithful
reader/writer for the tagged format, including retail's refusal of a file
that does not open with `<NEWP>`. Pure; no UI, no state ownership.
- **QJ2 — the owner.** `RuntimeJournalState`: the page collection, the current
page, new/delete/goto, and the timer. Per-character.
- **QJ3 — the Journal page.** Edit boxes, page navigation, Record, and the
editable/running timer swap.
- **QJ4 — the Page List page.** The list, the search, delete, and
double-click-to-open.
- **QJ5 — persistence.** Load on character enter, save on exit, under the
client's own data directory.
## Definition of done
1. A page written in one session is there in the next.
2. Every ported algorithm cites its retail address.
3. Every slice has a test that would catch its regression.

View file

@ -1,791 +0,0 @@
# Campaign VM — VisualMaster
**Date:** 2026-08-22 · **Status:** PLANNED — awaiting the owner's goal
**Phase id:** **Campaign VM** — named by the project owner on 2026-08-22
**Branch:** `claude/git-sync-status-5fb1d2` (= main + Campaign AR, `c51b07ef`)
**Predecessor:** [Campaign AR](2026-08-21-atmospheric-rendering.md) and its
[independent review](../research/2026-08-22-campaign-ar-review.md)
**Scheduling:** owner-directed, like Campaign AR. Does not displace M4.
## Goal
Make Campaign AR **provably safe to merge** and then **finish the look**:
close every review finding with evidence rather than assertion, put the
Tier-1 post stack in linear light so its controls mean what they say, and add
the one cheap, high-impact enhancement still missing from the discussed
scope — **gentle, believable wind in Dereth's foliage**, driven by AC's own
authored weather, opt-in through the same render-pack contract, with the
default retail-faithful path untouched.
Campaign VM is shipped when both sides of the final matrix pass:
- **pack off:** pixel-identical to the pre-campaign build outside the two
intended parity changes (terrain normals, building detail), with production
CPU/GPU/allocation within the pre-campaign noise envelope; and
- **pack on:** linear-light post stack, registered-or-removed detail fade,
moving foliage that the owner judges "real, not a screensaver", shadows
that move with the leaves, and all of it inside Campaign AR's declared
budgets.
## Operating model
The launcher/AR cadence, unchanged:
- **Fable** plans, verifies claims against source/decomp, writes the
closeouts, and drives the work order. Nothing in a closeout may claim more
than its artifact shows — every "passes" names the command and the lane.
- **Sonnet** implements each slice against the pinned contract below.
- **Opus** reviews every slice with two lenses (architectural / retail-
faithful) before its closeout; findings go through a fix round and a narrow
re-review.
- **The owner** is stopped for exactly three things: the cdb read on the
retail client (VM2), the visual gates (VM3, VM6, VM7), and the merge.
Rules binding on every slice: no workarounds (CLAUDE.md), no guessed
AC-specific constants — a number without a decomp/DAT anchor gets a register
row in the same commit — and the pack-off path is the oracle: any slice that
moves a pack-off pixel outside its declared mask is a bug, not a tuning.
## Lesson carried into every slice — a branch that exists is not a branch that runs
VM2 found that Campaign AR's #226 port, the 2026-08-21 findings doc it was
briefed from, and the AR review's "probably" were all describing the
**fallback** branch of retail's detail pass — the `stage == 0` framebuffer
blend that retail only takes when the adapter cannot advertise
`D3DTEXOPCAPS_PREMODULATE`. Real hardware (the owner's AMD, and anything
modern) takes the single-pass texture-stage branch, which computes a
different formula with the opposite sign of effect (darkens, not brightens).
The decomp was read correctly three times; what nobody did until VM2 was
spend two minutes reading the capability on a live client.
Binding rule for this campaign and after: **when retail gates a mechanism
behind a capability, preference, or `trysinglepass`-style switch, the port
is not done until the gate's live value is recorded** — a cdb read on the
PDB-paired client, cited in the port's note with the binary GUID. "Grep
named → decompile → port" answers what the code *says*; only the live read
answers what it *does* on the hardware the game actually ran on. A guess
about driver caps ("rarely advertised") is not evidence and must not appear
in a review verdict.
Corollary for brightness questions: the owner's live "too bright" (exposure
1.0 → 0.80) was the pack-on gamma-space tonemap (F4, VM3), not this. The
detail overlay's fallback brightening is +3 % on building shells only; the
single-pass path darkens by ~10 %. Neither explains a scene-wide level.
## Slice ledger
| Slice | Closes | Gate |
|---|---|---|
| VM0 | F1 — default-path invariance | **CLOSED 2026-08-22 — PASS**; [report](../research/2026-08-22-vm0-default-path-invariance.md) |
| VM1 | F2/F3 — #226 single-pass re-port + fade removal | **CLOSED 2026-08-22**`05970306`, `388457a7`, `ae651312`, closeout; Opus APPROVE |
| VM2 | F3 — which retail detail path ran | **CLOSED 2026-08-22** — single-pass; see [cdb note](../research/2026-08-22-vm2-retail-detail-path-cdb.md) |
| VM3 | F4 — linear-light post stack | **CLOSED — USER-ACCEPTED 2026-08-23 ("Good gate pass")**`87677f9c`, `51178f7c`, closeout; Opus APPROVE; live Holtburg, High pack, 2560×1440 fullscreen, day group 9 "Rainy", launched from the cloned config under `artifacts/owner-gate` on `132395e6` (after #424/#425 fixed during the gate) |
| VM4 | F5 — overclaiming docs, incl. the reviewer's own | **CLOSED 2026-08-22** |
| VM5 | F8 — volumetric banding; F7 filed | **CLOSED 2026-08-22** (#421) |
| VM6 | Foliage wind | **CODE-COMPLETE 2026-08-23**`0930c35d`, `39e8408c`, `43e3abed`, `a82959f1`, `fccba839`, `eec95535`, `754d59d9`, `1f151242`; five Opus review rounds (round 3 APPROVE; round 4 REJECT caught the overhead-sun regression, fixed in round 5; round 5 APPROVE, its four nits closed in `1f151242`); pixel proof [note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md) (14,993 robust wind px vs 65 floor, scenery only; wind-off frames match the plain pipeline at mean |Δ| 0.007); **CLOSED — owner visual gate USER-ACCEPTED 2026-08-23 ("Looks good")** — live Holtburg, High pack, day group 9 "Rainy" (Overcast), 1920×1080, launched from the cloned config under `artifacts/owner-gate`; the remaining §VM6 Acceptance steps (30 s single tree, shadow follows, indoors still, pack-off still, Clear vs Storm) are the owner's to tick or waive |
| VM7 | Closeout: full gates, register, roadmap, merge | **SHIPPED 2026-08-23 — owner: "accept 422, commit and merge with main"; #422 carried as an open watch item; merged to main as a fast-forward.** AUTOMATED ROWS PASSED 2026-08-23 — release gate 15,283/0/0, connected route PASS pack-off + High, VM0 invariance on the final binary (robust 510 px), performance matrix PASS, #422 re-characterised (pack-independent, ~2 %, no stack yet); [closeout note](../research/2026-08-23-vm7-automated-closeout.md). **Owner visual gates (VM3, VM6, VM7 matrix) and the merge OWED** |
Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (closed, user-accepted) → VM6 (closed, user-accepted) → VM7 (shipped; #422 carried). VM0 goes first because everything after it is measured
against the baseline it establishes.
---
## VM0 — Default-path invariance (F1)
**Why:** the only "pre-campaign oracle" is a 2×2 synthetic, one-draw
recording-device fixture. `WbDrawDispatcher.Rhi.cs` was refactored underneath
the default path. Nobody has compared real pack-off pixels or production
performance against `6c79d35c`.
**Implementation**
1. Build `6c79d35c` in a throwaway worktree (Release). Capture with
`tools/run-offline-pixel-gate.ps1 -Out artifacts/vm0/base` — the tool's
own documented baseline mode.
2. Capture HEAD pack-off twice with the same tool and camera set:
`BuildingDetailTextures=false` (isolates the A2 normal change) and
`=true` (the shipped default).
3. Extend the tool's compare step with a **terrain mask** (same mechanism as
the existing `sky-mask.png`): pixels whose depth/material belong to
`terrain_modern` are excluded from the identity assertion and reported
separately.
4. Assert: HEAD(detail=false) vs base — **zero** differing pixels outside the
terrain mask; inside the mask, differences are reported as a histogram
(expected: low-amplitude shading deltas, no structural change).
HEAD(detail=true) vs HEAD(detail=false) — differences confined to
building/EnvCell surfaces (second mask from the detail replay's own
command set).
5. Production performance A/B, **no automation observer, no validation
layers, uncapped Release**, at the CLAUDE.md production-profile camera and
at pinned dense Arwic: base vs HEAD pack-off. Record CPU/GPU p50/p95/p99,
FPS, `alloc_kb p50`, GC counts. The pre-campaign references are
519.7 FPS / 1.869 ms CPU p50 (profile) and ~3.0/4.9 ms CPU p50/p95 (dense
Caul, digest §render). Deltas must sit inside run-to-run noise; any
allocation growth on the pack-off path is a regression to fix in this
slice, not to file.
6. Add the masked comparison as a repeatable tool mode
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
**OUTCOME (2026-08-22): PASS.** Connected, visible-window, isolated-config captures: base+normals vs HEAD-off has zero strong static differences outside the animated lifestone at Holtburg and the open field; production CPU is 13% cheaper and allocation 25x lower on HEAD's pack-off path (no observer, uncapped Release). Three config traps (isolated FOV, the real Roaming settings still selecting the pack, minimized-window throttling) are recorded in the report. Replaced the planned terrain mask with a base+normals baseline (no mask needed).
**Acceptance:** the two pixel assertions in step 4 hold; the perf A/B shows
no default-path regression; the Fable report records the exact commands,
commits and artifact paths. If a regression is found, it is fixed here and
the report names the cause — no "accepted difference" without a diff that
explains it.
## VM1 — Remove the invented detail fade (F2)
**Why:** `RetailDetailTextureContract.FullDetailDistanceMetres = 10` /
`ZeroDetailDistanceMetres = 50` have no retail anchor. Retail's
`DrawBuilding`/`DrawEnvCell`/`RenderMeshSubset`/`SetDetailSurfaceInternal`
carry no distance term; attenuation is the LINEAR mip chain.
**Decision (Fable, retail-faithful rule):** remove the ramp. **Amended by VM2:** VM1 also re-ports the blend to the single-pass math (see the VM2 outcome) — the fade removal and the blend correction land together, with the `RetailDetailTextureContract` helper and tests rewritten around `Expected(base, detail, diffuseAlpha)`. Mip averaging
already converges the live category texture to its 1.033 mean factor.
**Implementation**
- Delete the two constants and `vDetailFade` from `mesh_detail.vert/.frag`
(`mesh_detail.vert:83`); the fragment outputs `applyFog(detail.rgb)` with alpha `detail.a * instanceOpacity` — fogged so the two-draw result collapses to retail's fog-after-combine pixel (VM1 review fix).
- Confirm the detail texture is uploaded with a full mip chain and sampled
LINEAR/LINEAR/LINEAR, WRAP — that *is* retail's attenuation; test it.
- Rename `RetailDetailTextureContract` members so nothing un-anchored is
labelled retail; keep the blend-factor helper and its tests.
- Update the #226 pseudocode note: strike the "10 m / 50 m" lines, add the
decomp citations above, and state that attenuation is mip-driven.
- If the owner later wants a ramp back, it returns as a **pack setting**
(enhancement), never on the default path.
**OUTCOME (2026-08-22): CLOSED.** Four commits: the single-pass re-port + fade removal (`05970306`); the interior shell/detail passes binding their own instance opacity — a pre-campaign stale-slot read (`388457a7`); the Opus fix round — the detail contribution is fogged so the two-draw result collapses to retail's fog-after-combine pixel, mip/sampler test, doc caveats (`ae651312`); and the closeout (AP-232 for the translucent-subset blend weight, the sampler test pinned to the production constant). Measured on Holtburg buildings: predicted +2.2/+0.66/+0.16 levels vs measured +2.17/+0.57/+0.16 (`artifacts/vm1`, grass/sky controls 0.00). Detail-on costs +0.3-0.5 ms CPU / +0.1 ms GPU at dense Arwic, still under the pre-campaign baseline. Opus dual-lens review: APPROVE WITH FIXES -> fix round -> narrow re-review APPROVE.
**Acceptance:** no reference to a distance fade remains in src or the note;
VM0's masked comparison re-run shows the only change is on detail surfaces;
at >50 m a building reads the same as at 60 m (mips), not a hard step.
## VM2 — Which detail path did retail hardware run? (F3, owner-gated)
**Why:** the port is of the two-pass framebuffer fallback. With
`m_caps.bCanDoSinglePassDetailing`, retail used stage-1 texture ops
(`PREMODULATE` + `BLENDCURRENTALPHA` — a lerp). The cap needs
`D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6), which consumer drivers rarely
exposed — probable, not proven.
**Implementation:** one cdb script (`tools/cdb/vm2-detail-caps.cdb`): attach
to the PDB-paired `acclient.exe`, `dt acclient!RenderDevice::render_device`
`m_caps.bCanDoSinglePassDetailing`, `m_caps.bTexOpDotProduct3`, and the raw
`m_D3DCaps.TextureOpCaps`; `qd`. Record the answer in the #226 note and the
review doc.
- `bCanDoSinglePassDetailing == 0` → the port is the path players saw; close.
- `== 1` → file the single-pass stage math as the correct target, register
the current blend as a bounded divergence, and schedule the re-port as its
own slice (it is a fragment-shader change only).
**Acceptance:** the value is recorded with the binary GUID and the date.
**OUTCOME (2026-08-22): `bCanDoSinglePassDetailing = 1`, `trysinglepass = 1`.** Retail on the owner's GPU runs the single-pass stage path: `lerp(base·diffuse, detail.rgb, detail.a·diffuse.a)` — a mild darkening, not the fallback's brightening. The #226 port targets the wrong path and is re-ported inside VM1 (blend `SRCALPHA+INVSRCALPHA`, output `detail.rgb, detail.a·diffuseAlpha`, neutral at `detail.a == 0`). Also read: `LandscapeDetailTextures = 0` is a real separate preference. Evidence and the exact stage math: [2026-08-22-vm2-retail-detail-path-cdb.md](../research/2026-08-22-vm2-retail-detail-path-cdb.md).
## VM3 — Linear-light post stack (F4)
**Why:** bloom threshold, ACES, Rec.709 luma, saturation and the 0.5
contrast pivot all assume linear light; they receive gamma-encoded retail
colours and the output is never re-encoded. `exposure = 0.80` is the
compensation.
**Design**
- The main-world intermediate stays gamma-encoded `Rgba16Float` — the world
pass and its alpha blending are retail's and must not change.
- Every pack read of world colour decodes once: `acdreamDecode(c) = pow(c, 2.2)`
(a named function in `atmospheric_common.glsl`; 2.2 is the retail-era
display assumption, documented as such — not sRGB piecewise, which would
imply a precision the source never had).
- Bloom chain, sun-ray composite and volumetric composite operate in linear.
- `atmospheric_filmic.frag`: exposure → ACES → grade → vignette in linear,
then `acdreamEncode(c) = pow(c, 1/2.2)` to the UNORM swapchain.
- Defaults re-tuned so the **neutral** preset reproduces the pack-off image
within the VM0 tolerance when every effect is at its neutral value
(exposure 1.0, bloom 0, tonemap mix 0, saturation 1, contrast 1,
vignette 0) — the existing "every effect can be set to neutral" acceptance
now actually holds numerically, and is asserted by a test.
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
re-derived in linear and presented for the visual gate. **Shipped truth
(not the pre-implementation guess above):** exposure stays at **0.80**
at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially
the same 0.6163 the owner called too bright under the old gamma-space
pipeline, so 1.0 reproduces the rejected look, not a corrected one. The
bloom **threshold stays 1.0** (a fixed point of both `pow(x,2.2)` and
`pow(x,1/2.2)` — 1.0 decodes and encodes to 1.0), while the **knee moves
0.45 → 0.73** (`AtmosphericPostProcessGraph.BloomKneeLinear`) and
**vignette-strength moves 0.12 → 0.245** (re-derived so the same accepted
12% corner darkening survives the encode step). See
`AtmosphericColorPipelineTests` for the pinned numbers behind each of
these.
Old-vs-new curve at the shipped exposure 0.80 (gamma input →
old-pipeline display value / new-pipeline display value; filmic
strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
applied directly to the gamma value versus `encode(acesFitted(0.80 *
decode(g)))`):
| gamma in | old display | new display |
|---:|---:|---:|
| 0.05 | 0.031 | 0.023 |
| 0.10 | 0.091 | 0.052 |
| 0.15 | 0.162 | 0.091 |
| 0.20 | 0.233 | 0.140 |
| 0.30 | 0.360 | 0.265 |
| 0.46 | 0.511 | 0.488 |
| 0.50 | 0.541 | 0.539 |
| 0.70 | 0.652 | 0.735 |
| 0.90 | 0.725 | 0.845 |
| 1.00 | 0.752 | 0.879 |
Shadows deepen slightly (0.050.30 gamma read darker), the crossover sits
near gamma 0.5 (old and new agree almost exactly there — expected, since
that is close to the 0.46 midtone the exposure was tuned against), and
highlights above roughly gamma 0.5 now read brighter instead of
compressing toward ACES's ~0.75 ceiling.
**Acceptance:** neutral preset ≡ pack-off within tolerance — proved by the
CPU mirror `AtmosphericColorPipeline` (`AtmosphericColorPipelineTests`,
half-an-8-bit-step identity over a 0..255 grey sweep) plus shader-source
pins (`AtmosphericPostProcessGraphTests`) guarding the decode/encode call
sites against silent removal. The real-frame masked capture **was run**
(retail vs High-with-every-effect-neutral, `artifacts/vm3`): 110,561 px at
`|Δ|=1` (float/pow round-trip noise, sub-visible) and 95 foliage-silhouette
pixels at `|Δ|≥5` (58 isolated, max 73 — cutout-edge rasterization between two
separate client runs; ambient motion is the alternative explanation), nothing
on any ground/building/water surface. Stage-1's luminance table re-capture is still **owed** at the
owner gate; owner visual gate: "same look as accepted, no clipping,
highlights roll off". Budget unchanged (two `pow` per pixel).
### VM3 owner visual gate — brief (read this, then play)
Launch normally (launcher or `ACDREAM_RETAIL_UI=1` env launch), Options →
Display → render pack **Atmospheric / High**. Every number below is measured,
not predicted; the sliders are live so you can tune while looking.
**What changed on screen, at the shipped defaults (exposure 0.80):**
| input tone (gamma) | before VM3 | after VM3 | Δ |
|---|---|---|---|
| deep shadow 0.10 | 0.091 | 0.052 | 43 % |
| shadow 0.20 | 0.233 | 0.140 | 40 % |
| mid-grey 0.46 | 0.511 | 0.488 | 5 % |
| 0.50 | 0.541 | 0.539 | 0 (crossover) |
| bright 0.70 | 0.652 | 0.735 | +13 % |
| highlight 0.90 | 0.725 | 0.845 | +16 % |
Midtones are where you left them; **shadows are deeper and highlights no
longer crushed** — that is the linear ACES toe/shoulder, not a bug. Offline
Holtburg hillside captures (`docs/research/evidence/vm3/`): mean luminance vs
pack-off **17 % at noon, 44 % at dusk** (codex's accepted look measured
8 % outdoors on a different scene). Vignette was re-derived (0.12 → 0.245) so
the corner darkening on screen is the same 12 % you accepted.
**Checklist (≈10 min):**
1. Noon outdoors, walk a shaded street: do shadows read as "deep" or as
"crushed"? If crushed → raise **Exposure** to 0.91.0 (mid-greys will
brighten ~10 %) or lower **Filmic strength** to 0.70.8 (lifts the toe,
keeps the shoulder).
2. Look at the sky near the sun and a bright roof: highlights should roll off
smoothly with no clipping (clip % measured 0.06, same as pack off).
3. Dusk (`/time` or wait): the 44 % is the effect of long shadows plus the
toe. Judge whether it is atmospheric or merely dark; the same two sliders
apply.
4. Corners: vignette should match the previous feel. If you had a saved
override of 0.12 it is now weaker (the default moved, overrides did not).
5. Switch the pack to **retail/off** and back: with every effect at its
neutral value the image is the pack-off image (measured: ≤1 LSB on 99.99 %
of pixels).
Say which slider values you settle on; they become the shipped defaults in
VM7. Known: #422 (1-in-8 heap-corruption exit after a pack-on run) is open
and is a VM7 gate item, not a VM3 one.
## VM4 — Truthful documents (F5)
**OUTCOME (2026-08-22): CLOSED.** Corrected in place, each with a dated
"VM4 correction" note: the AR plan's 2x2-oracle sentence and both
"zero skips" totals (now "under the hermetic lane filter"); the Stage-2
report's turning-hitch paragraph (observer tax, not product); the Track A
report's and the findings doc's `TerrainUtils.GetNormal` premise; the findings
doc's "retail brightens" section (fallback only; single-pass lerp is what runs),
its landscape-detail open question (`LandscapeDetailTextures = 0`, answered by
VM2), and its open question 5; the completion audit's retail-path row; and the
review's own F1/F2/F3/F5 headers. The VM0 production table is now the baseline
in the findings doc §5.
Correct, in the AR plan and its reports, the four sentences the review
named: the 2×2 oracle described as a production pin; the turning-hitch
conclusion drawn under the automation observer; the "zero skips" headline
(say "hermetic lanes"); and the Track A premise that `TerrainUtils.GetNormal`
produced a faceted look. Also correct the reviewer's own findings doc §4
(`2026-08-21-terrain-and-atmospheric-rendering-findings.md`): that function
orients scenery; the render normals were already smooth; A2 replaced
central-difference with retail's split-aware incident-face average.
Add the VM0 numbers as the new baseline table. Docs only; no code.
## VM5 — Volumetric jitter, and file the shared transform buffer (F8, F7)
- `atmospheric_volumetric.frag`: interleaved-gradient-noise offset per pixel
on the march start (`fract(52.9829189 * fract(dot(gl_FragCoord.xy,
vec2(0.06711056, 0.00583715))))`), plus the existing quarter/half-res
upsample. Automated: a fixed-camera capture's step-banding metric (row
autocorrelation at the step period) drops below the current value;
GPU delta < 0.02 ms.
- File issue: the shadow pass uploads a second transform buffer; a future
GPU-culling step should bind the main pass's instance SSBO. Not built here.
**OUTCOME (2026-08-22):** IGN jitter landed in atmospheric_volumetric.frag
(pack-on only); F7 filed as #421.
## VM6 — Foliage wind (the feature)
### What "real movement" means here
Trees do not wave like flags. Real foliage has **three motions at three
speeds**: the whole tree leans slowly with the mean wind (seconds), branches
swing at their own natural frequency (about one second), and leaves flutter
fast and independently (fractions of a second). Gusts come and go over tens
of seconds. Neighbouring trees are out of step. Trunk bases do not move.
Anything that ignores one of these reads as "screensaver" — which is what
the owner does not want.
### The rule for what sways (no guessing, data-driven)
- **Candidate set:** procedural scenery only — entity ids in the
`0x8XXYYIII` namespace (`ProceduralSceneryIdAllocator`, top nibble 0x8).
Landblock statics (fences, signposts, buildings), weenies, creatures and
players never sway.
- **Foliage subsets:** within a candidate, the **alpha-cutout material
subsets** (leaves, fronds, bushes, grass tufts). Rocks have none and stay
still; a tree's trunk is opaque and gets only the slow lean (below).
- **Opaque subsets of a candidate that also owns cutout subsets** (trunks,
branches) receive the slow lean only, scaled by height, never the flutter.
- A pack may **exclude** object ids (`FoliageExclusions`, a list in the
descriptor) for the rare scenery object that is cutout but not foliage.
There is no include list: the rule is the rule.
This is a render-only classification flag per batch — bits 1 and 2 of
`BatchData.flags` (`mesh_atmospheric.vert` already reads `flags`; bit 0 is
the #226 built-mesh marker). The dispatcher sets it once at classification
time from the entity id namespace and the subset's blend class; the pack-off
`mesh_modern` pipelines never read it.
### The motion (vertex shader, pack variant only)
Applied in `mesh_atmospheric.vert` and in all four
`directional_shadow_world_*` vertex shaders (constraint: **the shadow must
move with the leaf** — the caster and receiver displacement are one shared
include, `foliage_wind.glsl`, so they cannot drift apart).
Inputs: instance world origin `o` (translation column of
`Instances[i].transform`), vertex world position `p`, `time` (from
`uCameraAndTime.w`, already in the vertex stage), and the pack's wind block
(new `uFoliageWind` in `atmospheric_common.glsl`):
```
vec4 uFoliageWindDirection; // xy unit direction, z = mean strength [0..1], w = gust strength
vec4 uFoliageWindParams; // x = lean amplitude m, y = branch amplitude m, z = flutter amplitude m, w = max height m
```
All amplitudes are **metres at the canopy top**; they are pack settings with
bounded ranges, not shader constants.
```
h = clamp((p.z - o.z) / maxHeight, 0, 1) // 0 at the base, 1 at the top
k = h * h // bend grows with height²: bases stay put
ph = dot(o.xy, vec2(0.137, 0.291)) // per-tree phase from world position
g = 0.5 + 0.5 * sin(0.05 * t + ph) // gust envelope, ~20 s
+ 0.25 * sin(0.13 * t + 1.7 * ph)
s = mean + gust * g // instantaneous strength
lean = k * leanAmp * s * (0.8 + 0.2 * sin(0.35 * t + ph)) // slow lean
branch = k * branchAmp * s * sin(1.1 * t + ph + 2.0 * h) // ~1 Hz, phase runs up the tree
flutter = h * flutterAmp * s * sin(6.0 * t + 7.0 * fract(sin(dot(p.xy, vec2(12.9898, 78.233))) * 43758.5453))
d = dir * (lean + branch) + perp(dir) * 0.35 * branch + vec2(flutter) * normalize(vec2(cos, sin) of vertex hash)
p.xy += d
p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5) // bend shortens, it does not stretch
```
Opaque (trunk) subsets use `lean` only. Cutout subsets use all three. The
vertex hash decorrelates leaves on the same tree; the instance phase
decorrelates trees. The `p.z` term is the cheap length-preserving correction
so a bent canopy sinks slightly instead of growing.
**Deliberate divergence from the pseudocode above (review fix round A7):**
both `h`'s divisor and the `p.z` correction's divisor guard `maxHeight`
(`amp.w`/`uAtmosphereWindAmplitude.w`) with `max(maxHeight, 0.5)`, i.e.
`h = clamp((p.z - o.z) / max(maxHeight, 0.5), 0, 1)` and
`p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5)`. `wind-canopy-height-metres`
is an author-facing pack setting with no enforced floor; without the guard a
misconfigured near-zero canopy height would divide by a near-zero value and
either blow `h` up to a huge (then clamped) number with a discontinuous
derivative right at the base, or make the bend-shortening term explode.
Flooring at 0.5 m (half the shortest plausible sapling) keeps both terms
well-behaved for any authored value, including 0, and is invisible for every
realistic canopy height (the built-in default is 8 m). Both the shader
(`foliage_wind.glsl`) and the CPU mirror (`FoliageWindModel.Displace`) apply
the guard identically.
Normals are **not** rotated (Gouraud on a cutout leaf with flipped lighting
would flicker); AC's flat-lit foliage does not need it.
### Weather drives it (AC owns the weather)
The pack's `AtmospherePolicyDeclaration` gains a `FoliageWindByWeather` table
keyed by the DAT-classified `AcDream.Core.World.WeatherKind` — not the raw
`activeDayGroup` index, which carries no weather meaning by itself.
`WeatherState.cs` already classifies each day group's authored DAT name into
one of these five real kinds, and `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads that classification through the
frame — this table reuses it instead of re-guessing from the index:
| WeatherKind | mean | gust |
|---|---|---|
| Clear | 0.25 | 0.15 |
| Overcast | 0.60 | 0.35 |
| Rain | 0.85 | 0.60 |
| Snow | 0.35 | 0.20 |
| Storm | 1.00 | 0.75 |
plus a global **Wind** slider (02×) and an **Off** setting. Transitions
between day groups interpolate over the existing weather delta seconds
(`uAtmosphereWeather.z`), so a weather change never snaps. Wind direction is
a pack setting (default NE→SW, 225°); there is no authored retail wind
direction to read, and the register row says so.
### Indoor, shadows, physics, picking
- Indoors (`!IsOutdoor`) wind is zero — EnvCells have no scenery anyway.
- Shadow casters apply the identical displacement (shared include).
- Physics is untouched by construction: the collision BSP is the trunk and
nothing here touches Runtime or Core physics. The acceptance test asserts
the Runtime/physics diff is empty.
- World picking (`WorldPicker`) picks against the undisplaced mesh; a leaf
may be up to `leanAmp + branchAmp` metres from its pick volume. That is
accepted and registered (one row, "render-only foliage displacement");
nobody picks leaves.
### Budget and quality scaling
- Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target:
< 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix
tooling (`tools/run-atmospheric-performance-matrix.ps1`).
- Low preset: lean + branch only (no flutter). Medium/High: all three.
### Acceptance
- Automated: the classification flag is set only for top-nibble-0x8 entities'
cutout subsets (and lean-only for their opaque subsets); pack-off pipelines never
read the flag and VM0's masked comparison is unchanged; the shared include
is byte-identical between receiver and caster variants (test reads both
SPIR-V inputs' source); weather table interpolates without discontinuity;
budget row passes.
- **Owner visual gate**, in this order: (1) Holtburg outskirts, Clear, noon —
"barely moving, alive"; (2) same place, Rain or Storm (the WeatherKind
comes from WeatherState's classification of the active day group's DAT
name — `/time`-cycle day groups or wait for weather) — "clearly windy,
still not a flag"; (3) watch one tree for 30 s — gusts arrive and leave, neighbours
out of step; (4) the tree's shadow on the ground moves with it; (5) walk
indoors — nothing moves; (6) pack off — nothing moves. The owner's words
decide; the numbers above are starting points to tune live.
### VM6 outcome (implementation landed 2026-08-22; code-complete 2026-08-23 at `1f151242`; owner visual gate outstanding)
Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the
design above; a same-day fix-round commit corrected the weather-table key.
Settings (`BuiltInAtmosphericRenderPack.Settings()`): `wind-enabled` (bool,
default on), `wind-strength` (02×, default 1.0), `wind-direction-degrees`
(0360°, default 225 — no authored retail wind direction exists to read),
`wind-lean-metres` (default 0.25), `wind-branch-metres` (default 0.15),
`wind-flutter-metres` (default 0.05, forced to 0 on the Low preset),
`wind-canopy-height-metres` (default 8). The mean/gust rows live in
`AtmospherePolicyDeclaration.FoliageWindByWeather`, keyed by NAME
(`FoliageWindWeatherPoint.WeatherKind`, an exact ordinal match against
`AcDream.Core.World.WeatherKind`'s member names — `Clear` 0.25/0.15,
`Overcast` 0.60/0.35, `Rain` 0.85/0.60, `Snow` 0.35/0.20, `Storm` 1.00/0.75),
not the raw `activeDayGroup` index the original design used: the index
carries no weather meaning by itself, and `WeatherState.cs` already
classifies each day group's authored DAT name into one of these five real
kinds — the same fact `AtmosphericFrameInputs.Weather` /
`uAtmosphereWeather.x` already threads through the frame, reused here
instead of re-guessed. `RenderPackAtmospherePolicyEvaluation.FoliageWind`
matches by `weather.ToString()` and falls back to the declared Clear row for
an unlisted kind. Classification bits live in
`FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`)
cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in
`WbDrawDispatcher.ClassifyBatches` (world receiver) and
`AddDirectionalShadowBatches` (caster) from the identical four inputs
(entity id, `AtmospherePolicyDeclaration.FoliageExclusions` membership,
subset `TranslucencyKind`, and `ObjectRenderData.HasCutoutSubset` — computed
once per mesh, not per frame or instance). The shared displacement lives in
`foliage_wind.glsl`, called once each from `mesh_atmospheric.vert` and all
four `directional_shadow_world_*` (opaque/cutout, base/multiview) caster
vertex shaders, reading the ABI v2 `uAtmosphereClockWind`/
`uAtmosphereWindAmplitude` members `AtmosphericPostProcessGraph.ResolveFoliageWind`
resolves once per `frame.Serial` (so the caster, which runs first each
frame, and the receiver read byte-identical values). `FoliageWindModel` is
the CPU mirror pinned by hermetic tests. Register row IA-25. Owed: the
six-step owner visual gate above (implementation is otherwise code-complete
and the automated acceptance criteria pass).
**Review fix round (2026-08-22/23):** an Opus dual-lens review of the three
implementation commits found two blockers, both fixed. (A1) The procedural-
scenery classifier tested bit 31 alone instead of the full top nibble
`0xF000_0000 == 0x8000_0000`, which incorrectly matched `LandblockStatic-
EntityIdAllocator`'s `0xC...` namespace (fences/gates/building shells with a
cutout subset), the `0xDA11_D0xx` paperdoll id, and the `0xFFFF_FF01` portal-
tunnel id as procedural scenery — all three would have swayed.
`ProceduralSceneryIdAllocator.IsInNamespace` now does the exact top-nibble
test and `FoliageWindClassification.IsProceduralScenery` delegates to it.
(A2) `GroupKey` (the world-receiver instance-batching key) did not include
`FoliageFlags` while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing the same mesh subset coalesced
into one receiver `InstanceGroup` whose flags were whichever entity
classified it last — the "known limitation" paragraph this outcome section
used to carry. `GroupKey` now carries `FoliageFlags`, set exactly once at
group creation from the key, never re-stamped; the two placements now land
in two distinct groups and the receiver agrees with the caster by
construction. Two should-fix items also landed: (A3) the world receiver
pass now explicitly binds `UniformAtmosphericFrame` from the caster's own
per-frame binding (carried on `DirectionalShadowFrameBinding`) instead of
relying on Vulkan not resetting the caster pass's leftover binding; (A4) a
Setup-composed tree's opaque trunk part now gets the trunk flag by OR-ing
`HasCutoutSubset` across all of the entity's currently-resolved sibling
parts (`FoliageWindClassification.ComputeEntityHasCutoutSubset`) instead of
consulting only the trunk part's own (cutout-free) mesh data. Nits A5
(flutter hash relative to instance origin, not absolute world XY — fp32
precision at far landblock corners) and A8 (`FoliageWindExclusions` as a
`FrozenSet`) also landed; A7 is the divergence note on the `max(maxHeight,
0.5)` guard above.
**Review fix round 2 (2026-08-23):** a narrow re-review found one more
blocker. (F1) The PRODUCTION packed classifier (`WbDrawDispatcher
.PackedOracle.cs`'s `ClassifyPackedBatches`/`GetOrCreatePackedGroup` — the
route `RetailPViewPassExecutor.DrawPackedProductionRoute` actually draws
from) never computed `FoliageFlags` at all: it built its `GroupKey` with the
field defaulting to `0u` and never copied it onto the created
`InstanceGroup`, so production `BatchData.flags` bits 1/2 were always zero
for every scenery entity — the world geometry never swayed even though the
independently-classified shadow caster did, so shadows visibly swayed under
rigid trees. Both classifier call sites now compute `FoliageFlags` via the
identical `FoliageWindClassification.Classify` call and entity-scoped
`HasCutoutSubset` OR the classic path uses, and `GetOrCreatePackedGroup`
copies it exactly like `GetOrCreateInstanceGroup` always has. The G2/G3
classified-output digest (`AddOpaqueSubmissionGroup`/
`BuildTransparentSubmissionDigest`) now also folds `GroupKey.FoliageFlags`
into its hash — previously present in the key but never actually read by
either digest function, so the fold is correct and symmetric between the
two functions. **Review fix round 3 (N3) correction:** this does NOT mean a
classic-vs-packed divergence is caught today. `CompareClassifiedOutput` (the
method that reads this digest) only runs from
`RenderScenePViewFrameProductController.BuildAndCompare`, which has no
production caller anywhere in `src/AcDream.App/``FrameRootComposition.cs`
constructs the controller with a real dispatcher, but nothing ever calls
`BuildAndCompare` on it — and both of `RenderScenePViewFrameProductTests`'s
own callers construct the controller without the optional `dispatcher`
argument, so `_dispatcher` is `null` and `CompareClassifiedOutput` short-
circuits before reaching the digest at all. The fold is correct and ready
for the day this oracle is wired to a caller; it catches nothing until then.
(F2, medium) The delayed-alpha replay
path (`PrepareDeferredAlphaDraws`) hardcoded `Flags = 1`, dropping bits 1/2
for any group replayed through it — a trunk instance promoted into the
alpha-blend group mid-fade (the `#188` translucency-promotion case) would
stop swaying for the duration of its fade; now `1u | key.FoliageFlags`. (F3)
`ComputeEntityHasCutoutSubset`'s three call sites (classic, caster, and the
newly-fixed packed classifier) each allocated a closure over `_meshAdapter`
per Setup entity per frame; a new context-taking overload passes the mesh
adapter as an explicit argument to a `static` lambda instead, letting the
compiler cache one delegate for the method's lifetime. A3's test gap also
closed: `DirectionalShadowGpuTests` now has a companion test proving
`WbDrawDispatcher.BindDirectionalShadowReceiver` (made `internal` for this)
actually emits the `UniformAtmosphericFrame` bind with the exact
buffer/offset/size a `DirectionalShadowFrameBinding` carries, pairing with
the existing test that proves that binding carries the caster's real bind
forward untouched. F4 (the `foliage_wind.glsl` header comment) and F5 (a
comment at the receiver bind site: the caster's own `AtmosphericFrameBuffer-
Binding` has its seven ABI v1 members zero/Identity by construction — only
the two v2 wind members are valid — safe today because `mesh_atmospheric
.vert` reads this binding solely for wind displacement, a footgun for a
future v1-reading addition to that shader) also landed. F6, noted rather
than fixed, applies to BOTH of the classifier's caches: the classic route's
`EntityClassificationCache`'s `EntityCacheEntry`, and the packed
production route's `PackedProjectionClassificationEntry`/
`PackedClassifiedBatch.Key` (`PackedProjectionClassificationCache`). Both
bake `GroupKey.FoliageFlags` (hence exclusion membership) in at
classification time and neither is proactively invalidated when
`FoliageWindExclusions` changes — a newly-excluded or newly-included object
can show its previous classification until its cache entry is next evicted
rather than immediately on a pack switch. The two caches differ in HOW they
eventually recover: `EntityCacheEntry` self-heals per entity, on that
entity's own next eviction (e.g. a landblock demote/reload);
`PackedProjectionClassificationCache.BeginFrame`
(`PackedProjectionClassificationCache.cs:133-137`) instead clears its
ENTIRE cache in one shot whenever `RenderSceneGeneration` changes. Neither
mechanism is keyed to a pack switch specifically, so both are staleness
windows of unknown-but-bounded length, not an immediate reclassification.
Harmless with the pack off (exclusions are pack-scoped); not worth a
proactive invalidation sweep for a rarely-changing, pack-scoped list.
**Review nit A6 (reviewer-filed, landed round 3):** `ResolveFoliageWind`'s
`_windMean`/`_windGust` started at 0 and ALWAYS eased toward the weather
target by clock delta, with no distinction for a graph's first-ever
advance. Two consequences: a pinned clock (`ACDREAM_SKY_PHASE_SECONDS`,
the offline pixel gate's determinism pin) has delta 0 on every advance
after the first, so the wind reached only whatever fraction the first
(clamped-to-1-second) step produced and sat there forever — every offline
capture under-represented the motion; live, the first 10 s after a graph
is constructed (pack selection / login) spun up from dead calm even though
the weather already IS what it is, because there was no previous frame to
ease from. Fixed at the root: the first advance
(`_windFrameSerial == -1`, the constructor sentinel) now SNAPS `_windMean`/
`_windGust` straight to the target; every later advance eases over
`WeatherSystem.TransitionSeconds` exactly as before. A new
`SetWindClockSecondsOverrideForTesting` test-only seam
(`AtmosphericPostProcessGraph`, `_windClockSecondsOverride` no longer
`readonly`) lets a hermetic test advance the pinned clock by an exact,
deterministic amount between two resolves — proving both the first-advance
snap and that a SECOND advance still eases normally — without a real-time
`Thread.Sleep`.
**Review fix round 4 (2026-08-23): wind decoupled from the shadow gate.**
The reviewer's offline pixel apparatus caught a real design defect the
first three rounds' CPU-side reasoning could not see: foliage wind was
welded to "directional shadows rendered this frame." Evidence: at the
High preset with `sun-shadow-strength=0` and wind-strength 2 + 1 m
lean/branch amplitude, wind-on vs wind-off at the same pinned clock
differed by only 4965 px — inside the apparatus's own 22 px run-to-run
noise floor, i.e. no measurable motion at all. A CPU probe independently
confirmed `ResolveFoliageWind` itself was correct (first advance snaps
exactly to Clear's 0.25/0.15, the gate is 1, one graph instance) — the
correct uniform was computed but never reached the world pass. Root
cause: `DirectionalSunShadowRenderer.Render` left `_currentFrameBinding`
at its pure `Disabled` (no-buffer) default on both early-out paths
(`!environment.ShouldRender`, `ResidentWindowUnavailable`);
`WbDrawDispatcher.PipelinesFor`/`TerrainModernRenderer`'s matching
selection logic only chose the atmospheric receiver pipeline
(`mesh_atmospheric`, which alone `#include`s `foliage_wind.glsl`) when
`TryGetCurrentFrameBinding` returned true; with no buffer it always
returned false, so the world pass silently ran the plain `mesh_modern`
pipeline instead — which has no wind code at all. Because the shadow
gate is `ActiveDayGroupMultiplier = dayGroupPolicy × elevationResponse ×
strength`, this killed wind every NIGHT (elevation response → 0), at
user `sun-shadow-strength` 0, and under the portal/login cover — not
just in the artificial `strength=0` repro.
Fix (decouple, don't patch): `DirectionalShadowFrameBinding` gained
`IsBindableFor` ("a real current-frame allocation exists") separate from
`IsValidFor` ("...and it is Enabled with real shadow content" — the
volumetric pass still gates on this, unchanged);
`TryGetCurrentFrameBinding` now returns `IsBindableFor`. When the
built-in pack supplies an `AtmosphericFrame` binding (declared packs
never do, so they are unaffected), `Render`'s two early-out paths call a
new `PublishDisabledReceiverBinding`: it allocates one real ring slice
and writes a DISABLED `DirectionalShadowUniforms` block — every matrix
Identity, every control/bias term zero, `TextureAndFlags` all zero (bit
0 clear is exactly what `directional_shadow_receiver.glsl`'s
`acdreamDirectionalShadowVisibility` already reads as "no shadow, full
visibility" via its existing early `return 1.0`), and a UNIT light
direction `(0,0,1)` so a fragment shader's `normalize()` can never
produce NaN. `BindDirectionalShadowReceiver` and
`TerrainModernRenderer`'s shadow-buffer bind now check `Buffer is not
null` instead of `Enabled`, so this disabled block actually gets bound
once it is selected.
**Review fix round 5 (2026-08-23) correction to the paragraph above:** the
claim "the flag bit makes it numerically the plain lighting sum" was
false as written — F1 BLOCKER, found by the reviewer's own offline pixel
apparatus reading the shader source, not by a repro capture (the disabled
block's `(0,0,1)` direction differs too little from straight-overhead to
show up above the apparatus's noise floor at these amplitudes, so the
mislighting was invisible to that specific test even though it is real).
Both receiver VERTEX shaders (`mesh_atmospheric.vert`,
`terrain_atmospheric.vert`) sourced the sun direction used to compute
`directionalLit`/`vDirectionalLit` from the shadow block's own
`uShadowLightDirectionAndSource`, unconditionally — not only the
*visibility* term, which was already correctly flag-gated. Every shadow-
gated-off frame was lighting outdoor terrain and objects from the
disabled block's `(0,0,1)` placeholder — straight overhead — regardless
of the authored sun's actual position. The celestial shadow source
direction is not the authored light direction either, so publishing it
instead would not have restored parity. Fixed in the shaders themselves:
both receiver verts now branch on the SAME flag bit
(`(uShadowTextureAndFlags.w & 1u) == 0u`) and, when clear, use the EXACT
plain-pipeline expression (`-uLights[i].dirAndRange.xyz` in
`mesh_atmospheric.vert`, matching `mesh_modern.vert`;
`-uLights[0].dirAndRange.xyz` in `terrain_atmospheric.vert`, matching
`terrain_modern.vert`) instead of the shadow block's direction. Corrected
statement: with the flag bit clear the receiver shaders take visibility
1.0 AND fall back to the authored `uLights` direction (round 5), so a
gated-off frame matches the plain pipeline to within float summation-
order rounding (review fix round 6, N2: "numerically the plain pipeline"
overstated it — the direction expression is bit-for-bit, but the SUM is
not, because the atmospheric fragment's split ambient+point vs
directional accumulation reassociates the terms relative to the plain
pipeline's single varying, and terrain's two separate varyings vs the
plain pipeline's one do the same; measured mean |Δ| 0.007 on the offline
scene, well under the apparatus's 65 px floor). Wind is unaffected —
`foliage_wind.glsl` reads only the AtmosphericFrame half of set 3, never
the shadow block.
**Pixel proof after rounds 45 (2026-08-23, `eec95535``754d59d9`):** the same
apparatus that found the defect — High preset, `sun-shadow-strength=0` so
no shadow term can move, same pinned clock, wind on vs off, two captures per
arm so the repeat pairs measure the run-to-run floor — gives a robust wind
mask of **13,854 px** on `eec95535` and **14,993 px** on `754d59d9`
(amplified: strength 2, lean/branch 1 m, flutter 0.5 m) against a 1965 px
floor, and **1,402 px** at the default Clear strength; the wind-off frame on
`754d59d9` matches the pre-round-4 plain pipeline at mean |Δ| 0.007 (round
4's `eec95535` was 1.77 — the overhead-sun regression); every marked pixel is a treeline tree, hillside tree or shoreline
bush, and no house, fence, road, lifestone, ground, water or UI pixel moves.
The first method tried (clock pin 0 s vs 3 s, wind-off pair as the control)
is recorded as confounded in the note and must not be reused: the treeline
silhouette carries a bimodal 0-or-~280 px rasterisation churn between runs
that lands on either side of the subtraction by luck. Tool:
`tools/vm6/wind-pixel-proof.py`; note:
`docs/research/2026-08-23-vm6-foliage-wind-pixel-proof.md`.
## VM7 — Closeout and merge
- Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR
reference matrix re-run for the changed presets, VM0's masked comparison
on the final binary, the connected lifecycle/reconnect route.
- Register: rows for the wind direction constant, the render-only
displacement vs picking, and whatever VM2 decides; TS-52 stays retired.
- Roadmap: move Campaign VM to shipped; AR's "final owner gate" rows that
VM0/VM3/VM6 satisfy are marked so in the AR plan.
- Memory: a `project_visualmaster_campaign.md` digest entry (the wind rule,
the linear-light decision, the VM0 oracle recipe).
- Owner: final pack-off / pack-on visual matrix, then merge to main.
## What this campaign does NOT do
- It does not touch the retail-faithful default path except through VM0's
evidence and VM1's removal of an un-anchored constant.
- It does not add normal maps, PBR, SSAO, water, or a second weather system.
- It does not move trunks' collision, walkability, or anything in Runtime.
- It does not animate landblock statics, weenies, grass decals that are not
scenery, or creatures.
- It does not rebuild the shadow transform buffer (filed, VM5).
- It does not re-port #226 to the single-pass path unless VM2 proves retail
ran it.
## Proposed owner goal
> **Campaign VM — VisualMaster.** Drive
> `docs/plans/2026-08-22-visualmaster-campaign.md` to shipped on branch
> `claude/git-sync-status-5fb1d2`: VM0 proves the pack-off path is
> pixel-identical to `6c79d35c` outside the terrain/detail masks with no
> production perf or allocation regression; VM1 removes the un-anchored
> detail fade; VM3 puts the post stack in linear light with a numerically
> neutral preset; VM4 makes every document truthful; VM5 de-bands the
> volumetrics; VM6 adds weather-driven foliage wind (scenery-only, cutout
> subsets, three motions, shadows follow) behind the render pack. Fable plans
> and verifies, Sonnet implements, Opus dual-lens reviews every slice. Stop
> me only for: the retail cdb read (VM2), the three visual gates (VM3, VM6,
> VM7), and the merge. Every closeout claim must name its command, lane and
> artifact.

View file

@ -1,126 +0,0 @@
# Campaign CA — character advancement retail parity (#431 + the raise/train/specialize family)
**Status:** ACTIVE 2026-08-24. Owner-directed scope; #431 promoted here.
**Milestone:** M4 — Live in the world.
**Issue anchors:** #431 (promoted), #430 tooltips (SEQUENCED AFTER, not in
this campaign — needs the #409 client-wide tooltip system).
## Owner's report (2026-08-24, verbatim scope)
The raise flow "works from the GUI and everything" — the guess is the
refresh side "is not wired correctly." Required behavior:
1. Raising an attribute must update everything derived from it, in real
time: raising Endurance/Self/etc. must move the **vitals bar** maxima
(health/stamina/mana); raising a **vital directly** (secondary
attributes are XP-raisable too) must update the bar the same way.
2. **Skills must update in real time** both when their underlying
attributes raise and when the skill itself is raised with XP.
3. **Run speed must increase when Quickness is raised** — Run is
attribute-fed.
4. **Skills with no underlying attribute (e.g. Salvaging) need their own
handling** — no formula contribution, raise-only progression.
5. **Unknown/untested territory:** raising skills with XP, and
**specializing** skills; also the retail **respec flow** (quests /
item turn-ins that drop learned skills so a character can re-spec /
re-specialize). None of this has been exercised against ACE.
## Current-state survey (2026-08-24, verified in source)
- **Outbound: complete.** `CharacterActions` builds all four actions —
`RaiseVital 0x0044`, `RaiseAttribute 0x0045`, `RaiseSkill 0x0046`,
`TrainSkill 0x0047` — and `CharacterSheetProvider` wires the panel's
buttons to them through the Runtime command seam. This is why the GUI
"works": ACE accepts and applies the raises.
- **Inbound: the hole.** The ONLY private stat-update messages parsed
anywhere are the vitals pair `PrivateUpdateVital (0x02E7)` /
`PrivateUpdateVitalCurrent (0x02E9)`. The attribute and skill update
family ACE sends back after a raise is UNHANDLED — no parser, no
routing, nothing reaches `LocalPlayerState` (whose own doc says
attributes refresh "only at PlayerDescription / future
`PrivateUpdateAttribute`"). Post-raise, the client's attribute/skill
model is stale until the next full PlayerDescription (i.e. next login).
- **Consequences observed by the owner (#431):** derived skills don't
move when an attribute raises; run speed doesn't change with
Quickness. Both follow directly from the missing inbound family — the
recompute never triggers because the trigger never arrives.
- **Run-rate seam already exists:**
`PlayerMovementController.ApplyServerRunRate` (the #431 filing's own
pointer) — the wire echo path updates live run rate; what's missing is
driving it (and the formula-side skill totals) from stat updates.
- **Character state owner:** `RuntimeCharacterState` (J4.3) owns the
spellbook/local-player graph; new stat state routes through it, not
through a parallel store.
## Oracle targets (CA1 — DO FIRST, no guessing)
Per the mandatory workflow (grep named-retail → cross-reference ≥2 refs →
pseudocode → port → conformance):
1. **The inbound message family.** Pin exact opcodes + layouts from ACE
(`GameMessagePrivateUpdateAttribute`, `...Attribute2ndLevel` (vitals),
`...Skill`, `...SkillLevel`, `...SkillAC` as ACE names them; their
sequence-number semantics) cross-checked against Chorizite.ACProtocol
and holtburger. Our `PrivateUpdateVital.cs` already cites ACE's
`GameMessagePrivateUpdateAttribute2ndLevel` naming — extend the same
treatment to the whole family. Also pin what ACE sends for
TrainSkill/specialize responses and for skill-credit changes.
2. **Retail's recompute chain.** In named-retail: how the client applies
an attribute update — which cached values recompute
(`CACQualities::InqSkill` / skill formula with attribute divisors from
SkillTable, max-vital formulas, run-rate refresh via the movement
system). The SkillTable formula fields we already load for chargen
(`ChargenSkillAdvancement`) are the same divisor data — verify the
in-world recompute uses identical math.
3. **Attribute-less skills.** SkillTable rows with no formula
(Salvaging & friends): confirm retail's display/derivation for them
(base = trained ranks + augmentation only).
4. **Training / specialization semantics.** Credits accounting, what the
0x0047 response looks like, how specialization changes the formula
multiplier (specialized = ranks count differently), and what messages
carry it.
5. **Respec / untrain.** Identify the retail mechanism (quest/item-driven
skill refund) and what the CLIENT sees — expectation: server-driven
property/skill updates using the SAME inbound family, so no bespoke
client flow; verify rather than assume. Confirm ACE's implementation
surface for a test path.
## Slices
- **CA1 — oracle + research doc** (`docs/research/2026-08-24-advancement-wire-and-recompute.md`):
everything above, with decomp addresses and ACE file citations. Output:
the pinned message table + retail recompute pseudocode.
- **CA2 — inbound stat-update family.** Parsers for the
attribute/skill/(vital-level) private updates; routed as ordered deltas
into the J4 owners (`RuntimeCharacterState` / `LocalPlayerState`)
through the existing generation-gated seam. Conformance tests from ACE
byte layouts.
- **CA3 — derived recompute + real-time presentation.** One recompute
path (retail's formula) fed by CA2's deltas driving: character panel
skill rows, vitals bar maxima (attribute- and vital-raise both),
run-rate into `ApplyServerRunRate`'s seam, attribute-less skills
handled per oracle. Binding-seam tests (the #436 lesson: assert the
REAL composition binds the refresh, not just that VMs recompute).
- **CA4 — train/specialize/respec verification.** Live-vs-ACE for
TrainSkill + specialization (panel flow + credits), and the respec
path exercised as far as ACE supports; fixes as the oracle demands.
- **CA5 — connected gate script** (`docs/research/2026-08-24-campaign-ca-test-script.md`),
user-driven: raise Quickness → run speed visibly increases immediately;
raise Endurance/Self → vitals maxima move; direct vital raise; skill
raise; train; specialize; respec if ACE path exists. PASS = every
change visible without relog.
**Sequenced after this campaign:** #430 skill/attribute tooltips (needs
the #409 client-wide tooltip surface; the CA1 oracle should still note
where retail sources its tooltip strings while it is in the
neighborhood).
## Ledger
| Slice | Status | Evidence |
|---|---|---|
| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly |
| CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) |
| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests |
| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten |
| CA5 | FIRST DRIVE 2026-08-24 (partial) + fix round `bce17b3c` | §1 PASSED from the log (run total 40→433 live, each raise re-applied; zero exceptions). Drive harvested #440 (trained row stuck until next click — row refresh now driven by the authoritative record) and closed #430 (tooltips could never mount — rows lacked the popup locator). OWED at the next drive: §2 Endurance→stamina fan-out, §3.2 AP-73 ghost probe, §5/§6 re-check (train row moves immediately now), tooltip hover-dwell visual, and the three feel answers (run speed, advancement chat lines, ghost cycle). |

View file

@ -1,602 +0,0 @@
# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment)
**Status:** IMPLEMENTATION COMPLETE 2026-08-25 (+ gate-fix CT-GF1 review-closed: retail ancestor+self clip `989f6652`/`025108a8`/`90a0da68`) — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. **CT7 GATE PASSED 2026-08-25** — two rounds: full pass, then one regression (plugin markup text under the CT-GF1 self-clip, fixed `752782d0`) re-gated PASS. Owner retail observation recorded: the Titles divider IS visible inside retail's window while scrolling — a retail quirk the clipped rendering reproduces. NOT pushed to gitea (owner directive).
**Execution model:** Fable plans and coordinates; Sonnet implements each
slice; Opus runs the dual-lens review (retail-faithful + architectural)
per slice, then a fix round. No pushes to gitea until the owner says so.
**Register:** [STALE-INTENT CORRECTION 2026-08-25, at Campaign AS's AS5
re-review: this retirement never happened — CT3/CT4 NARROWED AP-109 and
Campaign AS AS5 later closed its rank-prefix residual; the row remains
ACTIVE-narrowed to CT4's FormatXp sliver.] Original intent: retire AP-109
(inert Titles page) when CT3+CT4 land; every deviation a slice introduces
adds its row in the same commit.
## Owner report (2026-08-24, screenshots on file)
1. Attribute/skill row **icons misaligned** vs retail.
2. Retail keeps a **margin between the value column and the border**
the gutter reserved for the list scrollbar that appears when the
window is resized shorter. We author no such margin and never show
the scrollbar on Attributes.
3. The character window is **resizable in Y down to an authored
minimum** in retail; ours is not.
4. Header identity block: retail shows the name, then
**"<Gender> <Heritage> <DisplayTitle>"** (e.g. "Female Aluvian War
Mage"), then **"Non-Player Killer" / "Player Killer" /
"Player Killer Lite"** in **pure white** — on Attributes AND Skills.
We show gender+heritage only, no PK line contract, color off.
5. **Level number color** slightly off vs retail.
6. **Titles tab is inert** (AP-109): retail lists all earned titles
(sorted), shows the current display title, and lets the player set
one ("Set as Display Title"); scrollbar with many titles; the
header identity line updates live when the display title changes.
7. **All windows share retail's authored minimum-size behavior**
resize clamps to the authored constraints everywhere.
## Retail recon (verified 2026-08-24, this session)
### Titles page — `gmCharacterTitleUI`
- `PostInit @0x0049A610` binds: display-title text `0x1000052F`,
"Set as Display Title" button `0x10000535`, title ListBox
`0x10000532`. Registers notice handlers for the title-table /
add-title / set-display-title notices.
- Rows carry the title id in attribute `0x1000008E`;
`AddTitleToList @0x0049A840` resolves the display string via
`CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string
table — CT2 locates the DID) and inserts SORTED
(`FindSortedInsertPosition @0x0049A760`). It writes the resolved title
text into row child `0x10000537` and stamps the row's id via
`SetAttribute_Enum(row, 0x1000008E, titleId)`, inserting the row via
`AddItemFromTemplateList(listBox, 0, insertPos)` — CT3 mirrors this
exact write shape when populating `0x10000532`.
- **CORRECTED (CT1 fix round 2026-08-24):** `UpdateButtons @0x0049A500`
— the display button is **GHOSTED (state 0xd) UNLESS a row is
SELECTED whose title id differs from the current display title; no
selection → Ghosted.** (Not "ghosts when selected == current" — that
phrasing had the no-selection case backwards.) Verbatim mechanism:
a no-match selection falls through to index `0xFFFFFFFF`
`GetItem` returns null → `SetState(0xd)`. Selection change
(msg 4/0x43) re-runs it.
- Clicking `0x10000535` sends
`CM_Social::Event_SetDisplayCharacterTitle(titleId)`
(`ListenToElementMessage @0x0049A6D0`).
- `gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50`
→ the stat panel refreshes its header when the display title changes.
### Wire (ACE cross-checked)
- Inbound `CharacterTitle` event `0x0029` (already in our
`GameEventType`): `u32 =1, u32 displayTitleId, u32 count,
count × u32 titleId` (`GameEventCharacterTitle.cs`).
- Inbound `UpdateTitle` event `0x002B`: `u32 titleId,
u32 setAsDisplay` (`GameEventUpdateTitle.cs`).
- Outbound `TitleSet` GameAction (`GameActionSetTitle.cs`):
`u32 titleId`. Retail sender: `CM_Social::Event_SetDisplayCharacterTitle`.
### Header identity — `gmStatManagementUI::PostInit @0x004EFD90`
Binds name `0x10000231`, heritage line `0x10000232`, PK line
`0x10000233`, level `0x1000023B`, total XP `0x10000235`, XP-to-level
`0x10000238` + meter `0x10000236`, luminance pair `0x100005C5/C6`, list
box `0x1000023D`. The refresh (vtable slot, near
`UpdateExperience @0x004F0A70`) composes the heritage line WITH the
display title; the PK strings are exactly "Player Killer" /
"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite —
cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads
the composing function verbatim before writing a line of C#.
### CT4 contract (read verbatim 2026-08-24, Fable)
`gmStatManagementUI::UpdateCharacterInfo @0x004F0770`:
- **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player,
NAME_SINGULAR)` through `AllegianceData::GetFullName @0x005B6950`
(read verbatim): when `AllegianceSystem::GetTitle(rank, heritage,
gender)` yields an allegiance rank title, the line is
"<RankTitle><sep @data_794098><Name>"; otherwise the plain name.
The owner's retail screenshot (plain "Dww") is the rankless case.
- **Heritage line** (`0x10000232`):
`AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71,
heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when
`CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves,
`AppendText(separator @data_794358)` + `AppendText(titleString)`
"Female Aluvian War Mage". PE-read RECOVERED (2026-08-24):
the separator `@data_794358` is a single space `" "`; the
allegiance-rank separator `@data_794098` is likewise `" "`.
- **Level** (`0x1000023B`): `InqInt(0x19)` present →
`Formatted(@data_7a0184)` = `"%d"`; absent → literal
`@data_7b0f34` = `"???"` (both PE-read recovered 2026-08-24).
- **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way
`IsPK` / `IsPKLite` / neither → StringInfo from table enum
`0x10000001` (= StringTable `0x23000001`, same compute_str_hash
mechanism the chat labels use) with keys
`ID_StatManagement_Header_PKStatus_PK` / `_PKL` / `_NPK`.
- Related key family for the footer/meter (already-shipped surfaces —
audit only if CT1 finds drift): `ID_StatManagement_Footer_*`,
`ID_StatManagement_Header_XPToLevelMeterInfinity`.
### Already in-tree
- Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId
0x10000539`); pages currently show retail-authored closed visuals.
- Header labels partially bound (`StatHeaderLine` + `PkStatus` seams
exist in `CharacterStatController.Bind` — content contract wrong).
- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now
(CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and
an outbound `TitleSet` builder — see CT2's paragraph below.
- The character window registers with `DatConstraintSource` — authored
min/max plumbing exists in `RetailWindowFrame`; Y-resize for this
window and the list-scrollbar contract do not.
## Slices
**CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed `ca4100e7`, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round `e264d839`.**
Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`;
9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections
now BINDING on later slices:
(a) the character root (`0x10000227`, Type-8 TabControl) authors NO
min/max constraints and `MountCharacter` wires no `DatConstraintSource`
— CT6 must first find retail's actual minimum mechanism in the decomp
(likely class behavior, not a DAT property);
(b) row templates are reachable ONLY via the targeted
`ImportInfos(dats, layoutId, elementId)` overload (`0x10000248` stat
row in `0x21000045`; `0x10000536` title row in `0x2100005E`) — the
plain import's #375 prototype-skip hides them;
(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4),
name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the
282px row edge; row Highlight media is `0x06000F93` (code uses
`0x06001397` — flagged, CT5 verifies).
Title chain verified end-to-end: titleId → EnumMapper `0x22000041`
(canonical key, e.g. `ID_CharacterTitle_War_Mage`) → compute_str_hash →
StringTable `0x2300000E` → text (id 13 = "War Mage").
Original scope: Live-DAT probe of layout
`0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built
rows), the value-column right margin, header element fonts/colors
(level `0x1000023B` color — item 5), Titles-page elements
(`0x1000052F/32/35` geometry, row template, scrollbar), window
min/max constraints. Output: research doc + InstalledDat pins (the
tooltip/scrollbar-pin pattern). No production changes.
**CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed `bcfddc97`, Opus review (0 blockers, 4 should-fix), fix round `544f8cb2`.** Parsed
`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack
@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is
discarded, matching retail's own read) and `0x002B UpdateTitle`
(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id +
setAsDisplay). New sibling owner `RuntimeCharacterTitleState`
(`RuntimeCharacterState.Titles`) holds the earned-title set + display
title id, clears at generation reset (`CaptureOwnership`/`IsConverged`
extended with `TitleCount`/`DisplayTitleIsDefault`), and fires
`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound
`TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle`
on both hosts (`DirectGameRuntimeCommandAdapter` direct-send,
`CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/
`LiveSessionCommandRouter` queue) — verified against retail's own
`CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends
the wire message and touches no local field; the display title updates
only from the server's own echo. No register row: this slice
introduces no retail deviation. App-layer `CharacterTitleResolver`
(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports
`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash →
StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays
id-only. Conformance tests against ACE's writer shapes
(`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`),
Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` +
`RuntimeCharacterStateTests.cs` integration), a wire-send command test
(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin
(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass.
**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX
corrections landed. **F1 (the important one):** the NOTICE broadcast is
unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires
regardless of prior membership), but the client-side table ADD is
DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990`
walks `mTitleList` and returns without effect when the id is already
present, only inserting + adding the row on a miss.
`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT
receive side, not the server send side) now fires `TitleAdded` only on a
genuine new membership; the inverted pin is
`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`.
**F3:** the send-side `titleId == 0` rejection is REMOVED from both
command adapters — retail's own send path
(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is
handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum
value); retail's actual protection is the UI ghost-when-current gate
(CT3's job), not a send-side rejection. No register row: removing the
guard makes acdream MORE retail-exact, not less. The fix round also
closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes
`TableReplaced` unconditionally and `DisplayTitleChanged` when the
display id was non-zero before the clear, matching the
`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState
.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of
`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/
`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags
computed inside the lock and events raised after release), and A5 (every
revision bump is now gated on an actual state change — a no-op wire
resend produces zero revision edges; `TableReplaced` itself still fires
unconditionally per retail's own `Refresh()` dispatch). A1
(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR`
first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id
precedent inline), A7 (corrected the "third consumer" comment — CT1 §5
already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third
consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored),
and A8 (`CharacterTitleResolver` now memoizes the final resolved string
per title id, the DAT-static equivalent of retail's lazy-hash cache on
the string buffer) round out the fix round.
**CT3 anchors from the CT2 review** (carried forward for CT3 to consume,
not yet acted on):
1. CT3 must refresh the display-title TEXT from `TableReplaced` as well
as `DisplayTitleChanged` — retail's
`RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es
on every `0x0029` arrival, not only when the display id differs.
2. ACE sends NO echo when re-setting the already-current title — the
Set-as-Display button must not wait for a confirmation that never
arrives; retail prevents the send in the first place via the UI
ghost-when-current gate.
3. Retail's fallback display text when a title id doesn't resolve is the
hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a
StringTable key — `CharacterTitleResolver.Resolve` returning `null`
is the correct signal for CT3 to substitute that literal.
4. The deduped client-side add contract (F1 above) — CT3's title-list
row rendering must not assume every `TitleAdded` firing corresponds
to a wire arrival; the reverse still holds (every genuine new row has
a `TitleAdded` firing).
**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round `4cc9448b`; full hermetic suite re-verified green after a load-flake false alarm.** Bind the authored page through the standard
GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero
bespoke widgets): sorted rows via the ported title-table lookup,
selection, ghost-when-current logic (state 0xd contract), display-title
text, Set-as-Display round trip, scrollbar. Retires half of AP-109.
**CT3 fix round (Opus dual-lens review, 2026-08-24).** BLOCKER: ported
`Refresh @0x0049abc0`'s unconditional `SetSelectedItem(nullptr, 1)`
(`@0x0049ac5a`) — selection now clears on BOTH `TableReplaced` and
`DisplayTitleChanged`, regardless of whether the previously-selected id
is still earned in the new table, but deliberately survives
`TitleAdded` (`RecvNotice_AddCharacterTitle @0x0049a990` splices one
row without ever touching `m_pSelectedItem` — a genuinely different
retail method from `Refresh`). SHOULD-FIX: `AddTitleToList @0x0049A840`'s
early-outs (`@0x0049a873`/`@0x0049a914`) ported — an id of 0, or an id
`CharacterTitleResolver.Resolve` fails to resolve, now produces NO row
at all (the `"Unknown"` fallback literal belongs only to the
display-title text, never a row — this was previously ported
backwards); rows use the row template's own authored `DefaultColor`
instead of a hardcoded white, and each row/display-text `UiText.Line[]`
is built once per text change and cached instead of reallocated every
draw call. Notes also applied: corrected two comments that falsely
claimed the Titles page authors its own copies of the raise buttons
(verified against the fixture — it does not; the hide loop that
comment guarded is a defensive no-op, kept only for the
contentPage-not-found fallback path), switched the row sort from
`List.Sort` to a stable `OrderBy`/`ThenBy` (ties broken by title id),
wrapped the title-resolver delegate in the same `DatLock` the
row-template resolver already takes (`RetailUiRuntime.MountCharacter`),
and set the list box's authored 24px row height so wheel/line scroll
lands row-aligned.
**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round `e7e32409`.** Retail composition: name; "<Gender>
<Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
(pure white per probe), live refresh on display-title change and PK
status, identical on Attributes AND Skills pages. Level color from the
authored element. Retires the rest of AP-109's UI half.
**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam
already covers both Attributes/Skills page copies — `CharacterStatController`
binds the SAME physically-visible container (contentPage = the Attributes
page chain) for both tabs; the Skills-page duplicate header subtree is never
shown (pinned by `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`,
a test that PREDATES CT4 — corrected at the CT4 fix round below, since the
original wording here implied CT4 wrote it fresh; the pre-existing test did
not cover `PkStatusId` until the fix round extended it).
All four header identity elements (Name/Heritage/PkStatus/Level) switched
from hand-picked `Body`/`Gold` runtime colors to the widget's own authored
`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly —
the former "runtime color, dat carries none" comment was false. PK status now
resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite
test (the prior exact-equality switch silently dropped combined-flag
values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK`
→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer"
(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`).
Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal
`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage
line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId`
resolved through `CharacterTitleResolver`, refreshing live on both
`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s
`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the
PLAIN-NAME case only — retail's allegiance rank-title prefix
(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle
@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table
(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g.
`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/
"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of
reasonable size for this slice; `RuntimeAllegianceState` already carries the
local player's own rank, so only the string table is missing. **Luminance
(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`,
PropertyInt64 6/7) already flows generically through both the
PlayerDescription snapshot parser and the live `0x02CF` private-update path
— no wiring gap existed — and the retail show/hide gate
(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is
wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the
label's caption and the value's composed number format could not be
recovered this slice (retail's `SetText` source resolves through a
Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT
string-table sweep found no match) — content stays unbound rather than
guessed. AP-109 narrowed accordingly (register row updated in the same
commit, not deleted — the two open items above remain). Tests:
`CharacterStatControllerTests` (heritage composition + live update, name
stays plain, level int/"???" with authored — not constant — color, PK line
shows resolved text in authored color, luminance visibility across five
level/luminance combinations) and `CharacterSheetProviderTests` (PK
key-by-status resolution including a combined-flag case, no-resolver ⇒ null,
Level null-vs-present, title resolution + live refresh on both title
events + unsubscribe-on-dispose, luminance Int64 read-through).
**CT4 fix round (Opus dual-lens review, 2026-08-25).** 2 BLOCKERS: (1) the
luminance caption/value strings were RECOVERED by PE-byte-decoding the raw
retail binary (caption UTF-16 `"Luminance:"` at `@0x007c3dd4`, value narrow
`"%s / %s"` at `@0x007c3dcc`, both immediately following
`gmStatManagementUI::UpdatePKStatus`'s own vftable slots — the CT4 landing's
"could not be recovered" claim is FALSIFIED), so the pair now binds real
text (each number formatted through a new shared `FormatXp` helper —
`.ToString("N0", InvariantCulture)`, also now used by Total XP / XP-to-next-
level, replacing their un-invariant `.ToString("N0")`), and the hide path
switched from `Visible = false` to retail's own `UIElement_Text::ClearAllText`
mechanism (`@0x004f0e31`/`@0x004f0e3c` — empty the LinesProvider, leave
layout); (2) `CharacterIdentityText.StripLeadingArticle` is deleted — retail
`AppendText`s the resolved title VERBATIM (`@0x004f0990`), and 26 real ACE
`CharacterTitle` entries begin with "The", so every one of them was being
mangled; the dead `CharacterSheet.Race` fallback (no retail producer — the
`InqGenderHeritageDisplay` creature-type argument is a hardcoded literal `0`
at `@0x004f08db`) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now
classifies off the live `ClientObject.PublicWeenieBitfield` PWD bits
(`0x20`/`0x02000000`, `ACCWeenieObject::IsPK`/`IsPKLite`
`@0x0058c8b0`/`@0x0058c8a0`) instead of a bitwise test against raw
PropertyInt 134 — PropertyInt 134 carries ACE's own `PlayerKillerStatus`
enum bit layout, not the PWD layout, so the deleted `0x4 | 0x8` combined-flag
test case asserted a non-retail answer (PropertyInt 134 already drives the
correct PWD bits via `PlayerKillerStatusBitfield.Apply`, so this is a
re-source, not new wiring); (4) the register's AP-109 row restores CT3's
Titles-page narrowing paragraph (CT4's edit had compressed it to a bare
pointer phrase), corrects the rank-prefix item's source to PropertyInt
`0x1E` (`AllegianceRank`) read live off the qualities bundle — NOT
`RuntimeAllegianceState`, which is a DIFFERENT UI's (`SocialAllegiancePageController`)
own documented substitute — corrects the title-table size from the
originally-estimated 22 functions/~200 strings to the ACTUAL 17
functions/~170 strings (`AllegianceSystem::GetTitle`'s dispatch switch read
directly: Gearknight/Tumerok author only a male function reused both ways,
Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades
the row's evidence claim to "synthetic-layout binding tests plus a small
number of InstalledDat string/DID pins" rather than implying a
connected/live gate; (5) `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
gains the luminance pair's own occurrence-count + font/color pins, matching
the pattern every other header id already uses. Also landed this round: an
InstalledDat pin (`GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain`)
proving `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName`
match the live retail `EnumMapper` chain (master map category 1 →
`ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs
`0x2200000A`/`0x2200000B`) byte-exact, including the two entries (10
"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses —
both are correct; the mechanism divergence (hardcoded table vs. live DAT
read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic
`RetailDataIdResolver.Resolve` helper (not a new "GetDIDByEnum helper" to
write) as the unification seam; `RetailAppraisalNameResolver.ResolveHeritage`'s
independent re-implementation of the same three overrides is noted there
too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from
a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one
`TryGetValue`.
**CT5 — Row alignment + value gutter. REVIEW-CLOSED 2026-08-25: landed `f532f28c`, Opus review (0 blockers, 4 should-fix incl. the authored Normal-state row band 0x06004CC2), fix round `0a37a28e`.** Reconcile our hand-built
attribute/skill rows with the authored row templates from CT1: icon
placement, name/value columns, the authored right margin that reserves
the scrollbar gutter.
**CT6 — Resize + scrollbar contract. REVIEW-CLOSED 2026-08-25: landed `ec50455a`, Opus review (1 blocker: chrome-inclusive host constraints double-counted; S4 default-height ruling = retail's 372px), fix round `996cd736`.** CT6 research lead (Fable,
2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo
@0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp
there, so the minimum lives in the generic Resizebar drag path reading
element attributes, and the authoring to probe is the PANEL-HOST layout
`0x2100006E`'s slot elements (Character slot per `RetailPanelCatalog`,
sibling of social `0x1000018F`) — NOT the character layout root CT1
probed. First CT6 step: probe the host slots' min/max + resize
authoring, then read `UIElement_Resizebar::StartMouseResizing
@0x0046B7E0`'s clamp source verbatim.
Original scope: Character window Y-resizable to
the authored minimum; the stat list shows its scrollbar when the
resized viewport overflows (the full-track/disabled behavior from the
2026-08-24 scrollbar work applies as-is); authored min/max constraint
enforcement verified as the STANDARD path for every registered window
(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`,
no per-window special cases).
**CT6 landing notes (2026-08-25, implementation).** Live probe (dumped
+ deleted, pattern preserved by the new
`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`
pin) confirmed the research lead's hypothesis exactly: the shared
`gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors
MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372,
MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar
(`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent
— matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s
`GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s
`GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The
Character/Skills slot `0x1000018E` itself authors no constraints of its
own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports
that host element and passes it as `DatConstraintSource`. **CORRECTED
(CT6 fix round, BLOCKER B1):** this paragraph originally claimed the
mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382,
MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host
`0x100005FE` is not a content element our wrapper adds chrome to; it IS
retail's own outer window frame (5px bevel + 300×362 content parent
`0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are
already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset
on top double-counted the bevel, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite
`ResizeX=false`. Fixed with a new
`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out
(chrome inset = 0 for constraint resolution when set — the value stays
DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets
it true. The mounted outer clamps are now EXACTLY what the host authors:
width fixed **310**, height **372..1000** — no composed arithmetic. A
new mount-time invariant in `RetailWindowFrame.Mount` (throws if the
just-mounted outer extent falls outside its own just-computed clamp)
would have caught B1 at the very first test run; it is now permanent for
every window this path mounts.
**S4 (2026-08-25, campaign-lead ruling — which number governs the
default mount size):** `0x2100002E`'s own root is authored 300×600 (the
"Size tension" the earlier ground-truth doc left unresolved — CT3's
Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real
authored canvas, but it is the CONTENT's own design surface, not the
mounted default — retail scroll-clips it into the shared host's much
smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter`
left `ContentHeight` unset, so it fell back to the raw 600px canvas,
producing a stale 610px mounted default (600 + 10px chrome inset) that
was never retail's actual opening size. **372 (the host's own outer
frame, 362 content + 10 chrome) is the number that governs the mount
default** — it is also exactly the host's own authored MinHeight, so
retail's Character/Skills window opens AT its resize floor and can only
be dragged taller, never shorter. `MountCharacter` now sets
`Options.ContentHeight = 362f` explicitly to realize this. The authored
page composition (header 112 + list 160 + divider + footer) IS the
362px design; at that default the 9 attribute/vital rows (180px content)
OVERFLOW the 160px list, so the stat list's scrollbar is active
immediately on open — retail-correct, not a regression (see S2 below for
what "active" actually looks like). Persistence still restores a
user-chosen size within the 372..1000 clamp on top of this default. Full
derivation + decomp anchors:
`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` §CT6.
`CharacterStatController.RebuildActiveList` now wraps BOTH the
Attributes and Skills tabs' rows in the same `UiScrollablePanel`
viewport (previously only Skills got one; Attributes rows were added
directly to the ListBox with no clipping/scrolling and the shared
scrollbar was force-hidden — the owner's item 2). The shared scrollbar
is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility
toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph
originally claimed `UiScrollbar`'s own `IsPresentationVisible`/
`IsModelDisabled` "draw the correct full-track 'disabled' thumb when
content fits (`HideWhenDisabled` defaults false)" — that had the
authored default BACKWARDS. `0x1000023E` (this scrollbar) and
`0x10000533` (the Titles list's own scrollbar) both author property
`0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true`
on both elements' property 121/0x79 in the committed fixture). A fitting
list HIDES the bar entirely; it does not leave a full-track disabled
thumb visible. The code was already correct — `.Visible = true` only
keeps the bar in the tree, `IsPresentationVisible` does the actual
show/hide — only this description was wrong; fixed here, in
`CharacterStatController.RebuildActiveList`'s own comment, in the CT7
script, and in `CharacterStatControllerTests`' comment, plus a new
`IsPresentationVisible` assertion pair added to the resize test (hidden
once growing makes the content fit, visible+interactive while
overflowing). This surfaced and fixed a real, previously-unexercised `#372`/
`#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom`
anchor was capturing its baseline margins lazily on its OWN first
`ApplyAnchor` call, which happens AFTER the ListBox has already grown
from its raw DAT height (160px) to its mounted height — measuring a
bogus non-zero margin that permanently capped the viewport short on
every later resize. Fixed with an eager
`viewport.CaptureCurrentAnchorBaseline()` call right after
`AddChild`, mirroring the identical fix already shipped in
`UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix
round, S3):** `CharacterTitlesController.Bind` originally gained the
same defensive `if (listBox.LayoutPolicy is null) Anchors =
Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that
`CharacterStatController` already had for its own list. Both
`0x10000532` and the Titles page container `0x10000539` author
`HasOriginalParentSize=true` in the real DAT AND the committed fixture,
which makes `LayoutImporter`/`DatWidgetFactory` always assign a real
`LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a
harmless no-op "matching the established pattern for synthetic/test
layouts" as originally described. Deleted rather than left as dead code;
a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize`
on both elements to guard the deletion against future DAT drift.
STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement
.MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at
`RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit
overrides, are the ONLY clamp fields — read identically by the
interactive drag path (`UiRoot`'s resize handling), the programmatic
path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s
main-panel geometry sync and this slice's tests exercise), and the
persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`).
`RetailWindowFrame.Mount` remains the single production mount path (no
window bypasses it). New regression pin (**renamed, CT6 fix round N4**:
the original name `NineSlice_ChatShapedConstraints_
ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises
NineSlice inset ARITHMETIC on a content-shaped source (490×100,
height-only synthetic constraints) and never actually pinned chat's real
DAT contract, since no width constraints were even set):
`RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize`
proves the same mechanism still clamps chat-shaped constraints after
Character was wired onto it. A new companion test,
`Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts
with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints
(matching production's actual `MountChat` wiring) and asserts no inset
applies — the true chat-contract pin the renamed test's name no longer
claims to be. Tests: `CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`
(window-level: clamp at authored min/max, list shrink, scrollbar
overflow flip, footer stays bottom-docked, grow-back restore) and
`CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips`
(same contract for the Titles list) plus the pre-existing 126+22-test
suites, all updated where the new nested-viewport DOM shape required it
(`Descendants(list)` instead of `list.Children` — the shape Skills rows
already needed). No register row: **CORRECTED (CT6 fix round, N5)**
before the B1 fix this sentence ("every number is either a live-probed
authored DAT value ... nothing inferred") was not actually true: the
mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive
values plus a second, redundant chrome inset composed on top). After B1
removes that composition, the mounted clamp is now literally the host's
own four probed values with zero arithmetic applied — the sentence holds
for real. No register row for the S4 content-height default either: 362
is the same host content-parent width/height CT6 already probed and
cited (`0x10000180`, 300×362), not a new number.
**CT7 — Connected gate.** Test script
(`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive:
titles round trip against ACE (earn/set/display), header lines vs
retail side-by-side, resize behavior, row alignment screenshots.
### CT-GF1 — client-wide retained-UI ancestor clip (gate finding + fix round)
Landed `989f6652`: ports retail's `UIRegion::DrawHere @0x0069FA30`
ancestor-clip intersection as `UiElement.ClipsChildren`'s new client-wide
default (true), fixing the CT7 gate's own first finding — the Titles page's
authored divider `0x10000530` escaping the Character window above its top
edge at the CT6-correct 372px mounted default. One opt-out
(`UiElement.ExpandsClipForPopup`, `UiMenu`'s inline-drawn popup) plus new
`UiAncestorClipTests` mechanism coverage.
**Fix round** (Opus dual-lens review, 0 blockers / 7 SHOULD-FIX / 4 NOTE, all
applied): moved the ambient clip to wrap `OnDraw` + children +
`OnDrawAfterChildren` in one block — the literal `DrawHere` shape, clipping
an element's own `DrawSelf` too, not just its children (`UIElement_Text::
DrawSelf @0x00467AA0`; `UIRegion::DrawSelf @0x0069F1A0`) — and deleted the
two now-redundant ad-hoc self-clips it superseded (`UiText.DrawText`,
`UiField.DrawMultiLine`); kept the one that clips to a genuinely smaller
authored inner rect (`UiButton.DrawBlockLabel`'s `LabelBox`/`ValueBox`).
Deleted `UiItemList`'s `ClipsChildren` override (inverted under the new
default). Pinned the escaped-popup input path end to end (`UiRoot.PopupHit`
routing, `WantsMouse`) with a new real-`UiRoot` test. Strengthened the
Titles-divider regression test's positive half (exact-rect assertion +
visible/hidden diff, not a bare Y-band check). Added a draw-capture
regression sweep across Character/Chat/Vendor/Options mounted through their
real controllers (`UiWindowDrawCaptureSweepTests`). `PushClipUnbounded` now
resets to the screen rect, not `null` — retail's own popup region is
screen-clipped, not truly unbounded (AD-113 amended). `UiRoot.ClipsChildren`
now explicitly overrides false (the root's own region IS the screen — a
safety net against a momentarily zero-sized root blanking the whole UI).
Added the empty-clip subtree cull (retail's `var_24` gate), scoped to the
main draw pass only — the popup's separate `DrawOverlays` traversal is
provably unaffected (new coverage: a menu inside a fully-clipped window
still draws its popup).
**Owed:** the CT7 re-gate (script `docs/research/2026-08-25-campaign-ct-test-
script.md`) still needs the owner's connected drive — this fix round landed
on the automated side only. §5 of that script now also names the
collapsed-toolbar check and the four highest-overflow windows (combat/
vitals bar, Options bottom-button row, map/house page, floaty chat) as
explicit eyeball items for that same re-gate.
## Review protocol
Per slice: Sonnet implements → Opus dual-lens review (lens 1
retail-faithfulness vs the cited decomp anchors; lens 2 architecture —
GUI-class standardization, Runtime ownership boundaries, no
controller-side state) → fix round → full hermetic suite green.
Commits to the worktree branch as slices land; **no gitea push until
the owner directs it**.

View file

@ -1,157 +0,0 @@
# Campaign AS — assess/examination window retail parity (player targets)
**Status: CLOSED — CONNECTED GATE PASSED 2026-08-25 ("fixed! gate pass!").**
AS1AS5 review-closed; the gate round harvested two findings, both resolved
in-round: the extras-list "black rectangle" is retail's own authored
scroll-less clipped listbox (no scrollbar authored on 0x10000335 — verified
against the live DAT; wheel-scroll and resize reveal rows; AS-GF1
`65f6f584` ruled it not-a-code-defect), and the paperdoll's absence
narrowed to an intermittent FIRST-OPEN DELAY (#443, kept open) after the
probe round proved the render layer healthy — the render pipeline was
never broken by this campaign. Gate probes deleted at close per the
probe-dies rule (recoverable via `git show 65f6f584`). Branch NOT pushed —
the owner pushes on their word.
Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both
assessing the player "Dww"): acdream's examination window on a PLAYER target
is missing retail's identity block and body/config sections.
Missing vs retail:
1. **Identity block** under the title bar: gender + heritage ("Female
Aluvian"), current display title ("War Mage"), PK status
("Non-Player Killer") — none of it rendered by acdream.
2. **Per-bodypart armor levels**: retail shows three grouped rows —
"Head/Chest/Groin AL: x/y/z", "Bicep/Wrist/Hand AL: x/y/z",
"Thigh/Shin/Foot AL: x/y/z". Absent in acdream.
3. **Target-configurable extras**: retail only shows what the assessed
player configured to show (date of birth, age, number of deaths, chess
rank, fishing skill, damage/crit ratings, …). acdream shows a
"Dmg/CritDmg Rating:" line but not the rest of the family.
4. **Allegiance/faction lines**: allegiance name/patron/faction shown when
the target has them. Absent in acdream.
Explicit owner rulings:
- **The animated 3D paperdoll is an INTENTIONAL acdream deviation**
(register row AD-114, filed at AS2). Retail's examine preview clone is
NOT a static tinted preview — it is INDEPENDENTLY ANIMATED, just
decoupled from the live target: `BasicCreatureExamineUI::Init
@0x004AB9C0` clones the selected object via `CPhysicsObj::makeObject
@0x005144B0` (which runs `MorphToExistingObject` then
`play_script_internal(setup->default_script_id)`), sets the clone's
heading to 191.367905°, and `CreatureMode::Render @0x004529D0` runs
`update_position` on it every frame. acdream's deviation is that our
preview mirrors the target's LIVE motion instead of playing its own
private, decoupled cycle. Keep ours. (Retail's preview colors are also
buggy on the owner's reference setup, so porting the decoupled clone
would not even be a faithfulness win.)
- Retail comparison is the oracle for text composition; all strings come
from DAT StringTables per the decomp — **never hardcoded English
literals**.
- All UI work goes through the standard GUI classes (UiLabel / UiPanel /
UiScrollablePanel / retail chrome) — no bespoke widgets, no quick fixes.
## Execution model (set by owner)
- **Fable** plans and coordinates (this doc + slice contracts + synthesis).
- **Sonnet** implements each slice against a pinned contract.
- **Opus** runs the dual-lens review per slice — retail-faithfulness lens +
architecture lens — followed by a fix round; slice is REVIEW-CLOSED only
after the re-review accepts the fixes.
- Commit to the worktree branch (`claude/windmill-seam-and-solid-polys`) as
slices land, full hermetic suite green each time
(`Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure`).
- **Do NOT push to gitea until the owner says so.**
- Campaign stops when all slices are REVIEW-CLOSED and the connected-gate
script is written and waiting on the owner's drive.
## Ground truth sources
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — the retail
examination window class, line composition, StringTable keys, 0x00C9
client-side parse.
- `references/ACE/` — what our live server actually sends per flag, and the
target-option gating rules.
- `references/Chorizite.ACProtocol/` — field-order cross-check.
- Campaign CT sealed verdicts (`claude-memory/project_character_panel_campaign.md`)
— the heritage/title composition and PK bitfield rules already ported for
the character panel; REUSE, do not re-derive.
- Research synthesis doc (AS1 output):
`docs/research/2026-08-25-campaign-as-ground-truth.md`.
## Slices (FINAL — re-cut at AS1: the 0x00C9 parse is already complete and
the appraisal profile is session-scoped UI presentation state flowing
through the established router seam, so the skeleton's wire and
runtime-owner slices are unnecessary; all work is App-side composition)
**Oracle for every slice:**
`docs/research/2026-08-25-campaign-as-ground-truth.md` (AS1 synthesis) —
its §2 line-composition tables, §3 wire truth, §4 gap ledger G1G10, and
§5 rulings R1R8 are BINDING on implementers and reviewers. Deviating from
a ruling requires a plan-doc amendment, not an implementer judgment call.
- **AS1 — ground truth synthesis (DONE, Fable).** Three research lenses
merged; gap ledger G1G10; rulings R1R8.
- **AS2 — header identity block (Sonnet).** Fix the element mis-mapping in
`AppraisalUiController.ApplyCreature(character: true)` per ground truth
§2a: `0x10000150` ← composed gender+heritage (reuse
`CharacterIdentityText`, including retail's heritage-id overrides and the
creature fallback when heritage==0); `0x10000151` ← current display title
via `CharacterTitleResolver` (Int 261, fallback String 5 Template);
`0x10000152` ← PK line from the assessed `ClientObject`'s PWD bits
(`PlayerKillerStatusBitfield`, ruling R7); `0x1000053A` ← String 47
AllegianceName gated on Int 30 ≥ 1, DELETING the invented
"Assessment incomplete" literal. First-ever `AppraisalView.Character`
controller tests (G10). Register: file the animated-paperdoll AD row
(owner-ruled intentional deviation 2026-08-25) in this commit.
- **AS3 — armor-level rows + extras-list plumbing (Sonnet).** Plumb
`Parsed.ArmorLevels` into the extras composer (signature change from
bare `PropertyBundle`); emit the spacer + three grouped AL rows before
the rating rows per ground truth §2b rows 37, including the `*%d`
≥9999 unenchantable rendering, retail's per-row rating gates
(307|313|314 · 308|315|316 · 350|351), spacer discipline per ruling R4,
and the `* = Unenchantable` legend per ruling R3 (unconditional,
gate-verified). Ordering pinned by tests.
- **AS4 — society/allegiance/fellowship + configurable extras (Sonnet).**
Ground truth §2b rows 12 and 814: the Society row with rank bands and
the local-vs-target faction color rule; the Monarch/Patron/Followers
cascade; Fellowship; Arrived in Dereth; Time in Dereth (locate or port
`ClientUISystem::DeltaTimeToString` — grep for an existing port first);
Chess Rank; Fishing Skill; Deaths ("Has never died" at ≤0); Titles
Earned. Register: narrow AP-110 ("exhaustive character detail regions"
clause retires) in this commit.
- **AS5 — allegiance rank-title table (Sonnet).** Port the 17-function
heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table (census
per AP-109's corrected 2026-08-25 text: 11 heritages → 17 functions,
~170 strings; Gearknight/Tumerok male-only reused, Lugian female-only
reused, Penumbraen aliases Shadowbound, Olthoi excluded by the unsigned
range check) + `AllegianceData::GetFullName @0x005B6950`; wire the
examination title bar (rank from `props.GetInt(0x1E)`, ruling R8) AND
the character panel's name line (closing AP-109's rank-prefix residual).
Register: NARROW AP-109 in this commit (CORRECTED at the AS5 review —
retiring would have deleted a live open item: CT4's FormatXp
`GetNumberFormatA` approximation sliver survives as the row's sole
remaining item, so the row stays active-narrowed).
- **AS6 — connected gate script (Fable).** User-driven script
`docs/research/2026-08-25-campaign-as-test-script.md`; two-client where
needed (allegiance/fellowship/PK lines, deception-failure rendering);
includes the R3 legend retail-side-by-side check and the owner's
configurable-extras toggle matrix.
Slices AS2→AS3→AS4 are SERIAL (all touch `AppraisalUiController` /
`CreatureAppraisalRows` — coupled-file rule); AS5 may run after AS4's
review closes. Each slice: Sonnet implements → full hermetic suite green →
commit → Opus dual-lens review (retail-faithfulness + architecture) → fix
round → narrow re-review → REVIEW-CLOSED.
## Ledger
| Slice | State | Land / fix commits | Notes |
|---|---|---|---|
| AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed |
| AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies |
| AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted |
| AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 |
| AS5 | **REVIEW-CLOSED 2026-08-25** | `8f8c0c3a` / `9f3e3263` | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active); re-review also flagged + this close fixed the last "retires" phrasing (`AppraisalUiController.cs`) and the CT plan's stale retirement intent |
| AS6 | **GATE PASSED 2026-08-25** | script `87e98395`; gate round `65f6f584` (AS-GF1) + probe-removal close commit | owner ran the gate live; two findings harvested and resolved in-round (extras clip = retail's authored scroll-less listbox, not a defect; paperdoll = #443 first-open delay, render layer proven healthy by probe); identity block, AL rows, allegiance/extras, and the rank-title title bar all owner-verified; probes stripped at close |

View file

@ -1,230 +0,0 @@
# Launcher content stabilization
**Date:** 2026-08-25
**Status:** IMPLEMENTED
**Goal:** make prepared-content updates fast, explicit, and safe without
turning the launcher into a package manager.
## User contract
1. The launcher window appears before network access, full-file hashing,
baking, recovery, or any other potentially long operation.
2. Ordinary startup reads only small metadata: the install record, pak header,
file length/write time, and the verification sidecar when present.
3. No long content operation begins silently. The launcher first names the
reason, work kind, approximate disk requirement, and whether the existing
installed game remains usable.
4. A prepared-content change uses a small locally generated overlay whenever
the affected DAT IDs/landblocks are bounded. A full rebuild is an explicit,
rare fallback for format changes or extraction changes with unbounded
impact.
5. The launcher never starts a mixed client/content pair. Cancellation or a
bake/publication failure preserves the prior pair. Once approved content is
ready, an unavailable or failed matching-client update leaves Play disabled
and retains the verified content for a cheap retry.
## What exists already
- `acdream.pak` has a 64-byte header containing DAT iterations, format version,
and `BakeToolVersion` (the current content-recipe identity).
- `install.json` records the pak SHA-256, size, DAT path, and recipe identity.
- `install.verification.json` avoids the former 24-second startup hash when
size/write-time still match. Explicit **Verify files** remains the full-hash
path.
- `acdream-bake` already accepts `--ids` and `--landblocks`, and a filtered
bake produces an ordinary valid pak with only those typed keys.
- `IPreparedAssetSource` and `IPreparedCollisionSource` are the existing
renderer/physics seams; no consumer needs to know which mapped pak supplied a
key.
- Launcher and client are published together, and the launcher payload already
includes the matching bake executable.
## Deliberately small model
There are only four work kinds:
| Kind | Launcher behavior |
|---|---|
| `None` | No content prompt. |
| `Overlay` | Build one cumulative overlay containing all keys changed since the base recipe. |
| `FullRebuild` | Explain the long rebuild and required free space before starting. |
| `Verify` | User-requested or exceptional recovery hash; always visible and cancellable. |
The release-feed schema remains unchanged for the first implementation. Every
published build already updates the launcher before the client. The updated
launcher carries the matching content requirement and a small compiled
migration catalog. This avoids stranding strict schema-1 launchers on a feed
shape they cannot parse. A future independently versioned content feed can
replace the catalog without changing the runtime content model.
`BakeToolVersion` is retained on disk for compatibility but is treated as a
**content recipe version**, not an executable build number. It changes only
when the produced prepared content changes.
## On-disk content state
The existing `install.json` remains the base-pak authority and is not extended;
older launchers reject unknown fields. New state lives in the optional sidecar
`DataDirectory/pak/content.current.json`:
```json
{
"schemaVersion": 1,
"baseSha256": "<sha256 from install.json>",
"effectiveRecipeVersion": 6,
"overlay": {
"path": "acdream-update-6.pak",
"sha256": "<64 lowercase hex>",
"size": 123,
"recipeVersion": 6
}
}
```
Rules:
- The sidecar is valid only when `baseSha256` binds it to the current base
record and every path is a safe canonical filename beneath the pak directory.
- At most one overlay is active. A later overlay is cumulative and atomically
replaces the prior sidecar; there is no unbounded lookup chain.
- The base and overlay must name the same installed DAT iterations and pak
format. The base may carry an older recipe; the overlay carries the effective
recipe.
- Missing overlay keys fall through to the base. A present-but-corrupt overlay
key is authoritative corruption and never falls through.
- Render and collision reads follow the same ordering and share the same two
memory mappings.
- An absent sidecar means the base pak is the complete active content set.
- `content.client-pending` is a separate, tiny crash-safe activation gate. A
content migration creates it before touching content and removes it only
after client compatibility is confirmed. It deliberately carries no package
graph; existence means “do not publish this content to Play yet.”
## Migration catalog
One compiled catalog entry describes each recipe transition:
```text
target recipe
work kind
player-facing reason
affected DAT IDs and/or landblocks (overlay only)
```
To update a base from recipe 5 directly to recipe 7, the launcher asks the
catalog for the cumulative 5 -> 7 impact and emits one recipe-7 overlay. If any
step is `FullRebuild`, the combined migration is a full rebuild. A missing
catalog step fails closed with an explanatory error; it never guesses.
The recent procedural night-sky change is `None` because it changed client
shader/code only. A future addition of bounded prepared sky keys can be
`Overlay`. A global mesh-extraction correction such as recipe 5's solid-face
change is `FullRebuild`.
## Update transaction and UI
The launcher keeps the existing one-question update surface. When the candidate
client needs newer content, pressing **Update** first opens the content-work
confirmation:
> **World data update required**
> This release adds prepared sky assets. acdream will build a small update
> from your installed Asheron's Call files. The existing game stays installed
> until this finishes.
> Estimated work: overlay / approximately N files / M free space required.
> **Update now** · **Later**
After confirmation:
1. Validate the remembered DAT directory and free-space floor.
2. Build to a transaction-owned candidate path while the active content stays
untouched.
3. Validate pak header/TOC and compute the new artifact's SHA once. Never hash
the unchanged base as part of an overlay update.
4. Atomically publish the content sidecar.
5. Install/activate the compatible client.
The newly prepared content is not published to the launch orchestrator until
the startup check confirms that the active client is compatible or the client
update succeeds. Choosing **Not now**, losing the network, or failing the
client download therefore cannot launch the old executable against the new
pak. The launcher keeps the verified content on disk and resumes at the much
smaller client-update step.
For `FullRebuild`, the same transaction builds a candidate base beside the old
base, verifies it, then atomically swaps the base record/file. It never moves
the playable base out of place before the long build starts.
Progress uses the existing strict Bake JSONL protocol and shows phase,
percentage, failures, and ETA. Cancellation returns to the launcher without
changing active content.
## Startup ordering
`App.OnFrameworkInitializationCompleted` must not synchronously wait on
`LoadExistingAsync` before constructing `MainWindow`. It constructs the shell
with an explicit `Checking` installation state, assigns/shows the window, then
starts content discovery on the UI dispatcher. Feed update checking begins only
after that cheap discovery completes, preventing two startup modals from
racing.
If an exceptional recovery path really needs a full base hash, the shell is
already visible and says exactly what it is doing. Launch stays disabled until
the recovery check finishes, but the application never looks frozen.
## Compatibility and rollback
- A client session receives the resolved base path plus zero or one overlay
path. Old clients continue receiving only the base.
- The client validates the effective recipe before constructing world owners.
- The updater does not activate a client whose content requirement is
unsatisfied.
- Choosing **Later** leaves the old client/base pair active.
- Client rollback is allowed only when the selected client accepts the active
content set; otherwise the launcher explains the required content rollback
or rebuild instead of launching an incompatible pair.
## Verification gates
- Launcher window construction test proves no installer/hash task is awaited
before the main window is assigned.
- Quick-discovery tests cover missing sidecar, matching sidecar, missing cache,
changed length/time, recipe mismatch, and exceptional visible verification.
- Content-state tests cover path containment, base-digest binding, atomic
publication, cancellation, and crash residue.
- Composite-source tests cover overlay hit, base fallback, authoritative
overlay corruption, render/collision parity, stats, and balanced disposal.
- Session-config round trips cover base-only and base+overlay on App and
Headless.
- Update tests prove prepared content cannot become launchable before client
compatibility is confirmed; **Not now** and client-download failure remain
fail-closed, while bake/candidate failure preserves the prior pair.
- Release solution compilation and the affected Launcher, Content, App, and
Headless gates remain green.
## Implementation checkpoint
Implemented 2026-08-25:
- The Avalonia window is assigned and opened before content discovery, client
recovery, feed access, or exceptional hashing begins.
- Ordinary current-install discovery uses metadata/header/cache checks; the
explicit verification command owns visible whole-pak hashing.
- Recipe migrations are compiled and cumulative. Bounded migrations build one
filtered overlay; unbounded/global migrations use the explicit candidate
full-rebuild path. The current recipe 4 -> 5 transition is correctly a full
rebuild because the solid-face extraction change is global.
- Base/overlay reads are unified for render and collision with overlay-first,
Missing-only fallback and authoritative corruption.
- Content activation is bound to client compatibility in memory and through
`content.client-pending`, so **Not now**, failed download, process crash, and
launcher restart cannot expose a mixed pair.
Final Release gates:
- `dotnet build AcDream.slnx -c Release`: 0 warnings, 0 errors.
- Launcher UI/ViewModels, excluding the documented manual desktop lane: 82/82.
- Launcher.Core Windows-compatible suite: 360/360.
- Hermetic Content suite: 130/130.
- Affected App layered/session composition: 35/35.
- Affected Headless configuration: 9/9.

View file

@ -1,483 +0,0 @@
# MossTank — VTank parity campaign
Date: 2026-08-26
Status: ACTIVE — MT1 USER-PASSED; MTUIMT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #452 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain
Research baseline:
`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md`
## Product definition
MossTank will provide the complete automation capability associated with
Virindi Tank, implemented as a first-class acdream plugin over a stable,
BCL-only plugin API. UtilityBelt's typed expression dialect is the scripting
baseline. Native file formats may differ; behavior and extensibility may not.
The finished surface is a visually verbatim VTank reproduction: every VTank
tab and function is present and every enabled control invokes real behavior.
## Non-negotiable boundaries
- modern code, behavior matched to documented VTank/retail behavior;
- one Runtime owner for every state/action; plugin API is a borrowed projection;
- policy engines remain in MossTank, not App or Runtime;
- plugin UI only through `IUiRegistry`;
- every API addition works in graphical and no-window hosts, with explicit
unavailable behavior until the host can genuinely supply it;
- no fake success and no silent expression-function omission.
## Slice ledger
### MT0 — research and campaign design
- [x] Reconcile existing VTank audit with current Runtime ownership.
- [x] Audit current UtilityBelt grammar and all 260 expression declarations.
- [x] Define complete capability ledger and staged architecture.
### MT1 — autocombat foundation (current stop gate)
- [x] Add target/combat views and attempt commands to the plugin API.
- [x] Project canonical hostile, selection, mode, power and spell state.
- [x] Implement target lock and range/angle/hybrid selection.
- [x] Implement melee/missile charge-release and direct offensive magic.
- [x] Deliver the polished combat dashboard and settings.
- [x] Focused, App/Runtime and complete solution gates.
- [x] Connected user gate: user confirmed autocombat works in the plugin.
MT1 intentionally does not pretend later features exist. It is “autocombat
ported,” not “all combat policy ported.”
### MTUI — generic plugin-window and VTank shell foundation
- [x] Add manifest-authenticated, stable plugin panel descriptors without
breaking API-v1 hosts/plugins.
- [x] Register plugin panels with the common retained window manager so
geometry and visibility persist.
- [x] Add the shared right-edge plugin shelf and window minimize/restore;
hidden panels leave the plugin session and automation running.
- [x] Add reusable nested groups, tabs, lamp toggles and sliders to retained
plugin markup.
- [x] Replace MossTank's dashboard/settings pair with one VTank-shaped shell
using the exact Options, Profiles, Vitals, Monsters, Items, Consumables,
Buffs, Route, Meta tab order.
- [x] Bind all currently enabled controls to real MT1/buff behavior and leave
unimplemented tabs visibly disabled.
- [x] Enable the Items/Consumables pages against durable, manifest-scoped
exact-name profiles; selection and Add/Add-no-buffs/Add-All-Peas controls
all mutate the policy consumed by combat.
- [x] Connected visual gate: shelf placement, minimize/restore persistence,
and first VTank-shell comparison in the live client.
### MT2 — complete monster/weapon/debuff combat policy
- [x] ordered `DEFAULT` + first-match monster rules;
- [x] priorities -1..4 and complete action-flag matrix;
- [x] damage/weapon/offhand selection, swap state machine and auto power,
including the official GameInfoDB exact-name overrides, ordered creature-
species preferences, and VTank's final elemental fallback;
- [x] debuff groups, skill/level choice, receipt-gated reapply and explicit
wand switching policy;
- [x] ring/arc/bolt density/range logic, streaks, Void, harm/martyr, grenades,
lenses, cast-on-strike and pets;
(carried phials are complete; crafting a missing phial belongs to MT4's
generalized craft transaction);
- [x] blacklist and both ghost-monster detectors, including canonical App
entity teardown for a detected client ghost.
MT2 checkpoint 2026-08-27: the BCL API now projects complete learned-combat
spell metadata, server cast and physical-attack receipts, health-update
revision/age, canonical equipment snapshots/commands, and exact-incarnation
ghost deletion. MossTank owns the complete Monsters expression/action model,
debuff tracker, elemental/shape spell catalog, range/density selection,
weapon/offhand policy, temporary blacklist, and both VTank ghost algorithms.
Focused evidence at this checkpoint: 104 MossTank tests, 20 Runtime action/
target tests, and an isolated Release App build all pass with zero failures or
warnings. MT2 remains open for automatic physical power and the four item-
backed combat families.
MT2 checkpoint 2 (2026-08-27): the official VTank assembly and live GameInfoDB
feed were inspected directly. Item appraisal SpellBooks are now retained;
plugins receive ordered combat chat and exact item UseDone receipts; the
source planner implements `dz.b.CompareTo` for SpellLevel/Skill preference;
the 72 official phials, lenses, cast-on-strike weapons and pets are executable
and profile-gated; proc success waits for the actual `You cast ... on ...`
line. `hi.cs` automatic attack power, including Recklessness clamping, is
ported verbatim. Items/Consumables profiles are atomically persisted through a
new per-manifest plugin-storage contract. Focused evidence: 131 MossTank tests,
the storage/chat/App tests, and an isolated Release App build pass. MT2 remains
open only for target-database `Auto` damage selection and the connected gate;
missing-grenade crafting is deliberately MT4 transaction scope.
MT2 automated closeout (2026-08-27): `Auto` now consumes the official 59-name
override and 103-species preference tables. The ordered element decision
outranks spell shape/tier, drives profiled physical weapon selection, and feeds
automatic attack power and vulnerability policy. Unknown targets preserve
VTank's final Pierce→Bludgeon→Slash→Acid→Lightning→Cold→Fire fallback. Focused
evidence after the closeout and named-profile foundation: 146 MossTank tests;
isolated Release App build 0 warnings / 0 errors. The connected MT2 combat
matrix remains part of the later combined user gate.
### MT3 — buff, heal and resource parity
- [x] named macro profiles, buff exclusions/item buffs/top-off foundation;
- [x] all three vital threshold tiers and canonical fellowship vitals;
- [x] profiled kits/consumables and worn-item mana recharge;
- [x] VTank ManaStone/ManaTank acquisition and exact-receipt fill behavior;
- [x] conversions, self/item/fellow dispel response, and critical/normal/idle
component plus six-category consumable upkeep.
### MT4 — inventory, craft and transactions
- [x] AutoStack/AutoCram and the official 757-row VTank craft database;
- [x] generalized use/apply/give/move/split/stack/drop transaction API with
receipts and busy arbitration;
- [x] retail 0x027D salvage and authoritative current-vendor sale paths;
- [x] same-input authoritative split crafting, all three split priorities and
exact VTank door/lockpick policy.
### MT5 — looting and extensible rule engine
- [x] corpse lifecycle/ID waits, exact 30-attempt/200-second open blacklist,
60-minute cache, 100-second public ownership, fellow Share Loot and rare-only
policy;
- [x] ordered first-match raw/projected-property expressions plus Keep,
KeepUpTo, Read, Salvage, Sell, ManaStone, ManaTank and User1User5;
- [x] canonical appraisal/pickup/salvage/vendor seams, unknown-scroll fallback,
and exact VTank salvage workmanship bands with 40-attempt abandonment;
- [x] independent By-char and named native loot profile documents;
- [x] exact VTClassic `.utl` v0/v1 importer/exporter, every structured
requirement, forward-compatible length blocks, and profile-owned salvage
ranges/value modes;
- [x] external loot-classifier plugin capability.
MT5 functional closeout (2026-08-27): the graphical host now exposes corpse
discovery, raw item properties, canonical appraisal/pickup, learned-spell
membership, fellowship Share Loot, retail salvage (0x027D), and current-vendor
sale through additive BCL-only interfaces. MossTank owns all policy and waits
for authoritative receipts/object removal; no action reports success at
dispatch. The official VTank corpse timers, rare/fellow ownership branches,
unknown-scroll difficulty check, mana-stone pairing, salvage-bag workmanship
bands, and bugged-bag retry ceiling were ported from the official decompiled
source. Focused evidence: 184 MossTank tests, 21 inventory-wire/session tests,
134 App automation/item/UI tests, and an isolated Release App build with zero
warnings/errors. File interoperability remains an MT9 compatibility tail, not
a reason to hold Route/Navigation.
### MT6 — navigation
- [x] canonical move/follow/turn/charged-jump/checkpoint host primitives;
- [x] circular, linear, once and Target/follow routes, including VTank's
endpoint reversal, destructive Once traversal and follow-around-corners;
- [x] every decoded nav node (0..9), closed-door/lockpick policy, vendor and
repeated NPC use, portal re-entry protection and combat/nav priority;
- [x] independent By-char and named native route profiles;
- [x] exact `uTank2 NAV 1.2` importer/exporter.
MT6 functional closeout (2026-08-27): the additive navigation API projects
VTank coordinates, live and server-accepted player position, object
reacquisition, door state, portal state, and typed movement levels through the
one Runtime command interpreter. MossTank owns the exact four route modes and
ten node types. Steering ports `fd.cs`'s 4° turn threshold, far 45° and near
15° forward cones; checkpoints use `gr.cs`'s accepted-position gate and
15-second nudge; Target mode ports `gl.cs` breadcrumb pruning; doors port
`b7.cs`'s defaults (disabled, 20 m ID, 4 m open, 50 lockpick threshold).
Portal2/UseNPC reacquire exact-name objects near the saved point, NPC use waits
for tell/give chat, jumps align to their stored heading before charge/release,
and Once removes completed rows exactly like VTank. Evidence: 204 MossTank
tests, focused App navigation projection tests, and isolated Release App build
with zero warnings/errors. Legacy file interop remains an MT9 compatibility
tail and does not hold the expression engine.
### MT7 — expressions
- [x] immutable AST, typed values, budgets and diagnostics;
- [x] UtilityBelt grammar semantics including lists/dicts/slices;
- [x] implement/alias/explicitly disposition the 260-function audit ledger;
- [x] VTank option/expression command diagnostics;
- [x] parser, evaluator, persistence and capability-security gates.
### MT8 — meta engine and runtime views
- [x] complete condition/action vocabulary, nested composition, once-per-entry,
call/return and watchdog;
- [x] chat capture variables and option access;
- [x] plugin-authored runtime views over the retained markup contract;
- [x] native meta profile;
- [x] exact VTank CondAct `.met` importer/exporter, including recursive rules,
embedded NAV and the historical CreateView record quirk.
### MT9 — fellowship, profiles, commands and polish
- [x] tell-driven recruitment, waiting-list, status/location commands, and
two-minute kick/ban/giveleader/setopen voting over canonical fellowship
commands;
- [x] helper healing, fellowship corpse permissions and shared target views;
- [x] macro-profile foundation: true per-character `By char` documents, named
create/copy/clear/select, mine-only filtering, hot loading, atomic manifest-
scoped storage, and complete current combat/buff/vitals/monster/item state;
- [x] independent navigation/loot/meta profile documents remain with MT5/MT6/MT8;
- [x] exact 137-name typed VTank option catalog/defaults and durable
`/vt opt setinall` across every indexed named/character macro profile;
- [x] all documented `/vt` command names are locally registered and handled;
- [x] exact `.nav`, `.met`, and `.utl` dumps/import-export;
- [x] privileged debug-operation semantics (`clearlocks`, `clearbusy`,
`fakeimp`) use canonical owners and authoritative lifetime cleanup;
- [x] first-run guidance, native/VTank profile migration and corrupt-profile
recovery with append-only raw-data preservation;
- [x] accessibility and scaling polish;
- [ ] performance soak, reconnect/lifecycle and multi-client gates.
## MT1 execution order
1. Add BCL-only combat records/interfaces with inert defaults.
2. Extend Runtime hostile query with exact position/heading snapshots.
3. Bind App's automation surface to the canonical action/spell owners.
4. Implement/test MossTank's deterministic combat controller.
5. Replace the small panel with dashboard/settings markup and generic markup
affordances needed by the design.
6. Run narrow tests, Release build, broad tests; record exact evidence here.
## Closeout evidence
MT1 code-complete 2026-08-26 and user-passed 2026-08-27. The additive BCL-only contract is
`CombatAutomation.cs`; older API-v1 implementations retain inert default
members. `AppAutomationSurface` borrows the canonical Runtime owners and
projects hostile captures, combat state, physical press/release attempts,
targeted casting and learned direct offensive spells. MossTank's
`CombatController` owns priority, target lock, range/angle/both selection,
mode entry, power-bar timing and magic choice. The dashboard/settings markup
uses the retained plugin registry; generic markup now supports bound child
visibility/enabled state and button colors.
Automated evidence:
- focused MossTank: 54 passed / 0 failed;
- complete Runtime: 1,854 passed / 0 failed;
- repository-owned hermetic Release gate: **15,775 passed / 0 skipped /
0 failed across 14 assemblies**;
- Release build: 0 warnings / 0 errors;
- the original MossTank XML documents parsed successfully before the gate.
MTUI code-complete 2026-08-27. `PluginPanelDescriptor` and authenticated
`PluginUiOwner` carry presentation metadata through Core's transactional
plugin lifetime; App mounts the stable panel as a `RetailWindowHandle` and the
generic `PluginSidePanel` owns only hide/restore UI. The one-window MossTank
shell uses real retained tabs/toggles/sliders. Focused evidence: 18 App/plugin
tests and 56 MossTank tests passed; isolated Release App build passed with
0 warnings / 0 errors. Broader hermetic evidence: Core 4,720/4,720 and Runtime
1,854/1,854 passed; App passed 6,441/6,442 with the sole failure in the
unrelated pre-existing landblock recenter assertion
`OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid`. Its
connected visual gate remains open.
MT3/MT4 resource closeout 2026-08-27: crafting now runs through VTank's three
ordered tiers: critical component/consumable recovery, normal component and
general profile crafting, then no-target idle component and six-category
kit/food stock targets. Same-input recipes wait for both the authoritative
split receipt and publication of two distinct stacks before applying. The
official `IdleCraftCount_*` underscore names, 4/20/20 component defaults, and
2/2/2 kit plus 15/15/15 food targets persist in named/By-char profiles.
Self-cast and item dispels port `c8.cs`/`cx.cs`; fellowship Awakener selection
ports `af.cs`, including exact training, Arcane Lore, 5 m, spell-3179 and
summed-vulnerability-quality gates. The additive shared duration-spell ledger
matches VTank's confirmed local/external `LogSpellCast` model and clears on
session detach. Evidence: 277/277 MossTank tests, 12/12 focused App automation
tests, and isolated Release App build with zero warnings/errors.
MT7MT9 checkpoint 2026-08-27: MossTank registers all 260 audited
UtilityBelt public expression names over the typed evaluator, and the Meta
runtime/editor, dynamic views, embedded routes, command execution and durable
variable scopes are integrated. The host now provides an unload-safe generic
plugin-command registry; `/vt` follows the same local command route from typed
chat, launcher login commands and no-window clients. The exact official
four-line command catalog and 137-row typed option database are present;
`setinall` rewrites every indexed named/character profile. Run Macro is now a
master lifecycle distinct from Enable Combat, and command jumps align before
charging. The additive fellowship API projects the canonical retail commands;
MossTank owns VTank's tell commands, wait list, spam limit, near-player
recruitment, leader transition cleanup and two-minute voting. Evidence at this
checkpoint: 261/261 MossTank tests, 18/18 runnable focused App/plugin tests,
and isolated Release App build with zero warnings/errors. Four additional
GraphicalPluginSession tests could not locate the repository when deliberately
run from an isolated OutputPath; this is test-harness path behavior, not a
product failure. Connected shelf/UI/fellowship and combined automation gates
remain open.
Legacy-profile checkpoint 2026-08-27: native JSON remains MossTank's durable
working format, while every save also emits a genuine VTank compatibility
file. `uTank2 NAV 1.2` routes and CondAct `.met` files round-trip exactly;
the Meta writer was independently accepted and canonicalized byte-identically
by the public `metaf` reference compiler. VTClassic `.utl` v0/v1 now retains
length-delimited unknown requirements/blocks, executes all 31 published
requirement types (including the DAT-resolved ordered-palette color family),
and applies per-material salvage ranges/value modes to the real 0x027D combine
planner. Native-only text rules export disabled rather than becoming
VTClassic's dangerous empty-requirement match-all. Evidence: 290/290 MossTank
tests, 13/13 focused App/plugin tests, and isolated Release App build with zero
warnings/errors.
External-loot checkpoint 2026-08-27: the BCL-only host now owns an unload-safe
classifier registry. Classifier ids are namespaced to the registering plugin,
all registrations are disposed transactionally with that plugin's session,
and exceptions are isolated at the registry boundary. MossTank exposes the
available engines in Profiles, persists the selection with the macro profile,
and runs Keep/KeepUpTo/Read/Salvage/Sell/User1User5 decisions through its
existing authoritative corpse executor. An unavailable engine never silently
changes policy by falling back to VTClassic. Evidence: 2 focused Core registry
tests, 55 focused MossTank loot/panel/markup tests, and isolated Release App
build with zero warnings/errors.
Options/debug checkpoint 2026-08-27: the VTank Options page now uses the
verbatim four-column control arrangement. Normal automatic rebuff, the
separate idle top-off window, Attack→Approach distance navigation, and final
Idle Peace fallback were ported from `fz.cs`, `cLogic.cs`, `g8.cs`, `eb.cs`
and `cm.cs`; Force Buff and Cancel Force Buff remain distinct actions. The
Advanced Options button opens the full ordered 137-setting table. `/vt
clearbusy` decrements exactly one Runtime-owned inventory busy reference,
`clearlocks` clears only MossTank's transient policy locks, and `fakeimp`
records VTank's local 3,000-second Gossamer Flesh debug marker without forging
a server cast. External classifiers now receive authoritative `OnLooted` and
`OnItemRemoved` lifecycle callbacks after inventory publication. Evidence:
298/298 MossTank tests and an isolated Release App build with zero warnings
and zero errors.
Final automated API/options checkpoint 2026-08-27: every one of the 137
official advanced-option names has an explicit writable live-policy mapping;
the full catalog, official defaults, case-insensitive lookup and durable
profile propagation are covered. The Monsters page now exposes the three
distinct official cycles for Damage type, Ex. Vuln and PetDmg rather than one
shared internal enum. Prismatic remains an ammunition policy while preserving
automatic magic-element selection; Fists uses Tusker Fists only while its
enchantment is active. `DoJiggle` now ports VTank's PreviousSelection followed
by alternating NextPlayer/PreviousPlayer at 131 ms and no longer moves the
character. `ShowCollisionDebug` publishes bounded projectile samples through
the BCL-only API and renders transient red/green markers in the retained UI.
`WhoYouGonnaCall` is intentionally stored but inert, matching the official
source's explicit `No Function` disposition.
The plugin API now projects combat, magic, equipment/items, looting,
fellowship, enchantments, navigation, world objects/time, login, network peer
state, recovery, projectile diagnostics and selection through canonical
Runtime/App owners. Startup peer tags are parsed once by `RuntimeOptions`,
portable data paths come from `ApplicationPathSet`, and both graphical and
headless plugin hosts load fixtures correctly from isolated output graphs.
Latest hermetic evidence: App 6,592 passed / 94 environment-dependent skips;
Runtime 1,863/1,863; Core 4,911/4,911; Core.Net 1,042/1,042; Headless
171/171; UI abstractions 880/880; MossTank 320/320 — **15,779 passed, zero
failed** across the selected automated lanes. The Release App build completed
with zero warnings and zero errors. Excluded gates are explicit: manual/live
lanes, Linux-only tests on this Windows host, the machine-local stale bake-tool
4 PAK test, and one registered pre-existing tower-ascent known failure. The
generic shelf, VTank shell, minimization-while-running, reconnect, live combat,
multi-client peer expressions, and collision-marker appearance remain owed in
the combined connected user gate.
Connected shelf/shell gate 2026-08-27: the first isolated Release launch found
that App's plugin-copy target still assumed each plugin's conventional `bin`
directory when a custom `OutputPath` was active. That caused the packaged
MossTank DLL/markup to be stale even though the root build outputs were current.
Build and publish now resolve both first-party plugin targets through MSBuild's
`GetTargetPath`; MossTank markup copies directly from its source. The rebuilt
package's MossTank DLL and XML matched their build/source SHA-256 hashes and
the boundary regression passed 5/5.
The next live launch exposed a retained-markup contract mismatch: one field
reused an `Action` button binding where `onsubmit` requires `Action<string>`,
preventing the complete plugin window from mounting. MossTank now has a typed
submit action and its markup contract test validates every interactive binding's
delegate shape. A later visual pass also caught three unsupported inline label
bindings on Meta; all are now whole-value properties, and the contract rejects
future inline interpolation. Focused MossTank evidence is 321/321; isolated
Release build `app-release22` is zero-warning/zero-error with exact packaged
artifact hashes.
The connected `app-release22` gate then passed: all nine tabs mounted and were
visually inspected; Meta rendered `State: Default`, `N: 0`, and `N2: 0`; the
right-edge `MT` shelf button was fully reachable; minimize hid only the window;
while hidden the live buff pass advanced from 91/97 to 77/97; restore showed
`Stop Macro` and the changed live status; the macro stopped normally. Logs show
92 server-confirmed `UseDone err=0` casts and no plugin/UI exception. Shift+Esc
completed the full logout presentation and returned to character selection.
This supersedes the earlier statement that the shelf, shell, minimization, and
basic reconnect/lifecycle presentation were wholly unproven. At that checkpoint,
still owed were
the accessibility/scale closeout, longer performance/reconnect soak, live
hostile combat matrix, two-client peer expressions/fellowship, and collision-
marker appearance.
Accessibility/reconnect/peer checkpoint 2026-08-27: textless and terse controls
now carry runtime-bound retained tooltips, and the common window owner clamps
plugin panels to the current viewport (including the 800x600 oversize case).
Focused evidence is 325/325 MossTank tests, 16/16 retained-UI tooltip/geometry
tests, and isolated Release `app-release23` with zero warnings/errors. The live
client displayed the Monster Range help text, completed a same-character
logout/re-entry, restarted the macro, and completed another 92 server-confirmed
casts. Working/private memory stayed approximately 1.59/1.84 GiB across the
combined soak rather than climbing with casts or reconnect.
The local peer API also passed real two-process expressions in both directions:
the secondary `+Horan` evaluated
`dictgetitem[listgetitem[netclients['mosstank-guard-primary'],0],'Name']` and
received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to
the primary; both heartbeat documents contained the expected names, tags,
vitals and positions.
That broader gate exposed separate client defect #452. First-chance cdb proof
located it in GLFW's Win32 event pump: temporary cross-process input-queue
attachment let `GetActiveWindow` return the other acdream process's HWND;
GLFW's shared `L"GLFW"` property then returned the other process's private
`_GLFWwindow*`, which the caller dereferenced. `app-release24` installs the
current-process HWND guard at GLFW's own import slot before `glfwInit`; its four
focused tests pass. Two rebuilt graphical clients then entered world, survived
100 rapid forced activation switches—the exact old trigger—and remained
responsive through a 30-minute combined soak with no native error. Issue #452
remains in-progress only until both sessions complete a graceful-exit gate.
The secondary-owned fellowship gate also passed: `+Acdream` created
`mosstankgate`, `+Horan` joined, both canonical rosters contained both members,
and the secondary evaluated `getfellowshipcount[]` as `2`.
Still owed here: the hostile combat matrix and collision-marker appearance.
Final local validation checkpoint 2026-08-27: the complete Release solution
build passed with zero warnings and zero errors. Focused MossTank passed
325/325 and the App plugin/API/UI/GLFW set passed 35/35. The conservative
Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed
1,042/1,042 in its isolated lane, for 16,125 passing selected tests. The first
max-parallel combined invocation made Core.Net's timing-sensitive two-percent
packet-loss soak exhaust its wall-clock headroom; the same case and complete
Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin
UI, Runtime-owner, or #452 guard test failed.
Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster
gate exposed two coupled compatibility defects. Retail's classic `* Lure`
vulnerability names were absent from the debuff classifier, so an attack-only
profile could misclassify Piercing Lure's "piercing damage" description as a
direct attack. The classifier now recognizes all seven classic elemental Lure
families (while excluding the distinct Lure Blade item spell), and the attack
catalog defensively rejects every host-authored debuff. Target evaluation also
now ports official `dz::a`'s previous-target tie-break after priority and manual
TargetLock: a valid chosen monster remains selected while the character turns,
instead of angle rescans alternating between surrounding monsters. The new
Lure/attack and target-stability regressions bring the focused MossTank lane to
337/337. Connected re-test remains part of the hostile combat gate.
## Requirement-level completion audit (2026-08-27)
Completion is deliberately **not** claimed while live evidence remains missing.
The authoritative requirement/evidence map is:
| Objective requirement | Current evidence | Audit result |
| --- | --- | --- |
| Functionally complete VTank behavior | MT2MT9 implementation ledger; 337 MossTank behavior/format/expression tests; connected MT1 autocombat acceptance | Proven for implemented policy and formats; the combined hostile physical/magic matrix remains live-unproven |
| Visually verbatim nine-tab VTank surface | `mosstank.xml` contains the exact Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta order; all nine tabs mounted in `app-release22` | Proven for shell/tab presence and first comparison; projectile debug-marker appearance remains live-unproven |
| Every visible control has real behavior | 190 interactive controls expose 202 bindings (191 unique); `MossTankMarkupContractTests` resolves every binding, verifies delegate shape, and rejects handlerless controls; 137/137 advanced options have explicit writable mappings | Proven statically and by focused controller tests. `WhoYouGonnaCall` intentionally stores its value but performs no action because the official VTank source labels it `No Function` |
| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven |
| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success |
| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live |
| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #452 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #452 cannot close until both current sessions exit gracefully |
Open completion gates: (1) hostile physical and offensive-magic behavior against
a live target at valid configured range; (2) visible green/red projectile
collision markers with `ShowCollisionDebug`; (3) graceful exit of both current
soak clients with no native or managed failure. These are evidence gaps, not
redefined-away acceptance criteria.

View file

@ -1,164 +0,0 @@
# PAK v2 resource campaign
Status: CLOSED — SHIPPED (2026-08-27)
## Objective and release gates
Ship one crash-safe prepared-asset format migration that:
- reduces the complete installed package from 29,908,271,024 bytes to at
most 5 GiB;
- preserves decoded geometry, material metadata, texture bytes, deterministic
baking, corruption isolation, and random-access loading;
- does not regress cold or warm world-reveal latency or frame-time percentiles;
- reduces offline bake time and live client CPU/GPU memory where the data
permits it, without changing the rendered result; the one-time bake remains
bounded for the user-confirmed 16-32 GiB target machines;
- gives launcher users a clear one-time update message and progress, while
retaining the last verified package until the replacement is validated;
- passes two different worker-count bakes with identical SHA-256, the complete
installed-DAT bake, content equivalence, performance, solution, Windows CI,
and release gates.
## Measured format-1 baseline
The installed package was parsed from its actual TOC, not estimated:
| Partition | Physical blobs | Physical bytes |
|---|---:|---:|
| GfxObj render meshes | 15,318 | 9,306,115,868 |
| Setup render meshes | 5,935 | 4,078,139 |
| EnvCell render meshes | 17,117 | 20,232,745,510 |
| All collision payloads | 12,938 | 30,392,894 |
| EnvCell topology | 729,888 | 255,512,622 |
| TOC | 2,232,170 rows | 53,572,080 |
Total: 29,908,271,024 bytes. Render payloads account for approximately
29.54 GB and 99% of physical payload bytes. The collision and index data are
not the size problem. Format 1 already aliases duplicate complete EnvCell
blobs, but each remaining mesh embeds another copy of every decoded RGBA
texture it uses.
Historical complete-bake baseline: 80.5-107.5 seconds, 4.43-4.89 GB peak
working set, and 3.81-4.21 GB peak private bytes.
## Format 2 contract
The 64-byte header and 24-byte sorted TOC row remain fixed. Format version is
2 and bake recipe is 6.
1. A new `TexturePayload` key partition (type 8) owns globally shared texture
byte arrays. Mesh payloads store the texture payload key while retaining
their own exact dimensions, format, upload metadata, surface identity,
translucency, culling, and index data.
2. Texture payload keys are the first 56 bits of SHA-256 under the type-8
namespace. The writer retains the full SHA-256 digest and length for every
unique texture, making even a truncated-key collision a loud bake failure
rather than silent substitution without pinning another copy of all bytes.
3. Every physical blob is independently encoded. The high bit of the TOC
length marks compression; the low 31 bits are the stored length. A
compressed blob contains a four-byte decoded-length prefix followed by
Brotli. Small or insufficiently compressible blobs remain exactly raw.
CRC-32 covers stored bytes, then decompression is independently validated.
4. Random access remains one binary search plus one mmap copy for raw blobs.
Compressed blobs add decompression only when the writer proved a material
size win. Texture payloads use a bounded, thread-safe 64 MiB / 1,024-entry
LRU; concurrently decoded meshes converge on one shared array instance.
5. Whole-file compression is forbidden. It would destroy random access and
make a small world reveal depend on unrelated content.
6. Unedited DAT DXT1/3/5 surfaces retain their exact BC1/2/3 source blocks
through bake, mmap, and Vulkan upload. Clip maps and surfaces with authored
translucency still decode to RGBA8 because their per-surface alpha edits
require pixels. This is smaller and closer to retail's hardware DXT path
than the former unconditional software decode.
## Determinism and publication
Asset traversal and mesh serialization remain sorted. A texture is emitted at
its first deterministic encounter, so its physical order is independent of
worker completion order. Aliases preserve the source row's exact offset,
encoded length/flags, and CRC.
Recipe 5 to 6 is a mandatory full rebuild. The launcher builds
`acdream.pak.candidate` beside the active package, validates format, recipe,
DAT iterations, TOC counts, size, completion protocol, and SHA-256, then uses
the existing atomic promotion/backup transaction. Cancellation or failure
keeps the verified format-1 package. No overlay may cross this format change.
## Complete installed-DAT evidence
The first installed-DAT mixed sample (four GfxObj, three Setup, three EnvCell,
all corresponding collision/topology payloads) produced 58 keys, 29 globally
deduplicated texture payloads, and 57 physical blobs. Decoded payload was
3.5 MiB and stored payload 1.0 MiB (3.62x); output was 1.0 MiB. Eight-worker
and three-worker bakes had the identical SHA-256
`78886DFA28A3EDF9368A1E25C9B02A3B69ADC5DFCC01358D64A073B549B5B532`.
The final complete four-worker and nine-worker bakes are byte-identical:
- 2,237,866 logical keys and 786,892 physical blobs;
- 5,696 globally shared texture payloads;
- zero extraction/validation failures;
- 597,229,424 bytes (569.6 MiB), down 98.0% from 29,908,271,024 bytes;
- SHA-256
`37BC0EA1778F899AF9E3B2397937D373F69D615B15D3E32041BF389D93B624BF`;
- 28.7 seconds for the final four-worker bake versus 79.1 seconds for the
same-machine recipe-5 format-1 baseline (63.7% faster).
The exact before/after connected lifecycle route used the same machine,
server, 1280x720 Vulkan presentation, retail render pack, and matching client
code. Both runs passed fresh login, multi-world portal travel, same-location
revisit, fresh-process reconnect, and graceful teardown.
| Matching live measurement | Format 1 | Format 2 | Change |
|---|---:|---:|---:|
| Heavy-route final working set | 2,621.9 MiB | 1,363.9 MiB | -48.0% |
| Heavy-route final private bytes | 2,470.5 MiB | 1,825.6 MiB | -26.1% |
| Holtburg prepared-mesh GPU bytes | 229.6 MiB | 170.1 MiB | -25.9% |
| Fresh reconnect working set | 974.1 MiB | 869.0 MiB | -10.8% |
| Fresh reconnect private bytes | 1,253.8 MiB | 1,166.6 MiB | -7.0% |
| Fresh reconnect to checkpoint | 59.3 s | 59.3 s | equal |
Rynthid and Facility transition times were equal; Holtburg completed 2.9 s
faster; Aerlinthe revisit differed by 0.1 s. Matching stable CPU p50/p95 rows
were equal or slightly faster and GPU time remained within 0.1 ms. The first
raw login samples were excluded from comparison because the server started
them in different cells with 6,671 versus 11,799 world entities. Matching
screenshots preserve geometry, materials, lighting, transparency, texture
detail, and mip behavior.
Automated gates at this checkpoint: 181/181 installed-DAT Content tests,
16,151/16,151 CI-filtered Windows tests, Release build with zero warnings, and
the authoritative connected lifecycle/reconnect gate all pass.
## Release closeout
Main fast-forwarded cleanly to `45ba42a3`. Gitea Actions run 206 passed the
Windows gate, Linux portable gate, and release job. The published alpha is
`0.1.0-build.202608271848`; the stable `latest` pointer carries the 45 MiB
Windows client, 78 MiB Windows launcher, and update manifest.
The first remote run exposed one locale-only contract failure: the launcher's
disk-space guidance rendered `2,0 GiB` under the runner culture. The shipped
fix formats the value invariantly and exercises the failure path under
`sv-SE`; the exact follow-up local Release gate again passed 16,151/16,151.
The exact shipped App binary also passed the connected lifecycle/reconnect
route (`connected-world-gate-20260827-204249`) with only the 25 expected
world-edge misses.
## Work ledger
- [x] Measure the format-1 package by TOC partition.
- [x] Implement and unit-test format-2 external texture references, adaptive
independent compression, corruption handling, bounded sharing, and byte
determinism.
- [x] Integrate format-2 accounting and strict validation into the bake.
- [x] Publish the recipe-6 mandatory full-rebuild launcher migration.
- [x] Add launcher disk-space preflight and explicit long-work detail.
- [x] Complete installed-DAT equivalence and dual-worker full bakes.
- [x] Measure/tune package size, bake time, read CPU/allocations, cold
and warm reveal latency, and frame-time percentiles.
- [x] Evaluate source-native BC texture retention only if it remains visually
exact and does not shift mip-generation work into the reveal frame.
- [x] Pass complete local tests and authoritative connected gates.
- [x] Pass Gitea Windows CI, merge, push, and release gates.

View file

@ -12,11 +12,11 @@ The command verifies that `AcDream.slnx` contains every `.csproj` under `src/`,
then discovers and runs every hermetic test in every default test assembly once
in a fresh Release process. It does not retry failures. Tests carrying an
explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`,
`Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
`Manual`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
`Status=KnownFailure` are excluded from the hermetic total and run through
their owned lane instead. The graph currently contains 54 projects,
including all 17 maintained .NET tools and three render-pack SDK samples;
data-dependent tools and SDK samples are built but are not executed as tests.
their owned lane instead. The graph currently contains 44 projects,
including all 13 maintained .NET tools; data-dependent tools are built but are
not executed as tests.
Build and dependency policy is repository-owned:
@ -72,40 +72,6 @@ The JSON summary records the exact test filter. Environment-dependent,
diagnostic, manual, and known-failure results must be published as their own
lane and must never be added to the hermetic pass headline.
## The Timing lane
`Lane=Timing` marks tests whose outcome depends on **real elapsed time or OS
scheduling** rather than on logic: simulated packet-loss soaks, a virtual-clock
transport session that still waits on wall-clock windows, signalling a real
child process, orphaned-process restart recovery. They pass on an idle machine
and fail intermittently under full-assembly load, so they cannot gate a push
without making the gate untrustworthy.
They are not weakened or deleted — run them deliberately, on a machine that is
not saturated:
```powershell
pwsh ./tools/run-release-gate.ps1 -SkipRestore -SkipBuild `
-TestFilter 'Lane=Timing&Status!=KnownFailure&Purpose!=Diagnostic'
```
Measured before laning: on the 6-core Linux runner, three stress rounds of the
full suite failed `GracefulStopSignalSendsSigintToARealChildOnLinux` 3/3 (it
passes in ~47 ms alone) and two loss-simulation tests 1/3 each. Chasing them one
at a time did not converge — four separate fixes, each surfacing a different
member of the same family, and one of those fixes regressed the other platform.
Add to this lane only with evidence that a test fails under load and passes in
isolation. A test that fails consistently is a bug, not a timing lane member.
## Continuous integration
This document owns the LOCAL gate. Pushes to `main` are gated on self-hosted
runners and publish alpha releases — see
[`ci-and-releases.md`](ci-and-releases.md). Note that CI deliberately does NOT
invoke `run-release-gate.ps1`: that script redirects child output to log files,
and Forgejo fails a task that stops reporting as a zombie.
## Non-hermetic test lanes
Installed-DAT tests require an explicit opt-in and a retail DAT directory:

View file

@ -1,236 +0,0 @@
# Render-pack SDK v1
**Campaign:** Atmospheric Rendering / Shader Packs
**Phase id:** **Campaign AR**
**Contract version:** `RenderPackApi.Current == 1`
Render packs are opt-in, declarative graphics extensions. acdream's current
retail-faithful renderer is always installed, remains the default and
authoritative comparison path, and is restored as one complete transaction
when a selected pack cannot run. A
pack cannot access Vulkan, renderer internals, gameplay state, world streaming,
or physics.
The public dependency is only
`AcDream.Plugin.Abstractions`. Do not reference `AcDream.App`, Silk.NET, or a
Vulkan binding. Three buildable external samples cover the API:
- [`AcDream.RenderPacks.NoOp`](../../samples/AcDream.RenderPacks.NoOp/) is the
smallest discovery and activation conformance pack.
- [`AcDream.RenderPacks.AtmosphericTier2`](../../samples/AcDream.RenderPacks.AtmosphericTier2/)
declares the complete semantic atmospheric executor with deliberately
renamed pack-owned IDs, embeds all referenced SPIR-V, and demonstrates
moving authored sun-and-moon shadows for terrain, trees, buildings, players, and monsters.
- [`AcDream.RenderPacks.ShadowsOnlyTier2`](../../samples/AcDream.RenderPacks.ShadowsOnlyTier2/)
demonstrates that Tier 2 is composable: it requests the same selected-celestial
caster/receiver semantics without Tier-1 post-processing or volumetric
shafts.
## Quick start
1. Target `.NET 10` and reference `AcDream.Plugin.Abstractions` with runtime
copy disabled. The acdream host supplies that assembly.
2. Add [`plugin.json`](plugin-manifest-v1.schema.json), include
`"kinds": ["renderPack"]`, and copy it beside the built entry DLL.
3. Expose exactly one public, parameterless `IRenderPackPlugin` entry point.
4. Construct immutable `RenderPackDescriptor` values and register them from
`Register`. Registration must only publish declarations; do not open assets,
compile shaders, start threads, or allocate native/GPU resources.
5. Supply shader bytes lazily through `IRenderPackAssets.OpenRead`. Asset keys
are forward-slash relative logical paths: never rooted, backslash-based, or
`.`/`..` traversals.
6. Build and run the SDK validator:
```powershell
dotnet build samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj -c Release
dotnet run --project tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj -c Release -- samples/AcDream.RenderPacks.NoOp/bin/Release/net10.0
```
Substitute `AcDream.RenderPacks.AtmosphericTier2` in both paths to validate
the complete Tier 2/Tier 2+ example and its embedded shader interfaces.
The validator executes the managed registration entry point. Use it only on a
pack you trust. It loads no App, RHI, or Vulkan assembly and creates no GPU
objects. It validates the manifest, v1 declarations, referenced asset keys,
SPIR-V stage/entry point and complete v1 binary interface, managed
registration, and duplicate pack IDs. Hardware and driver compatibility remain
client-side activation checks.
To launch a visible offline preview of acdream with the built-in Atmospheric
pack and a disposable settings profile:
```powershell
.\tools\launch-atmospheric-preview.ps1 -Preset High
```
The preview starts `AcDream.App` with audio disabled, clears inherited
`ACDREAM_*` live/automation/diagnostic state for that child, and leaves the
user's normal acdream settings untouched. It records the binary identity,
selected audio mode, and separate stdout/stderr logs beside the disposable
profile. To exercise OpenAL explicitly, add `-EnableAudio`.
Vertex and fragment asset keys are independent opaque keys; they do not need
matching basenames or a host shader-directory stem. On explicit selection the
client opens each declared stream, validates it, copies the bytes into the
isolated candidate, and creates shader modules from those immutable blobs.
The pipeline retains neither the stream nor a path into the plugin directory.
Each stage must be little-endian, word-aligned SPIR-V no larger than 16 MiB.
Shader-visible pack settings are deliberately capped at 64 declarations. The
public `PackSettings` binding and value encoding are documented in the
[`semantic binding table`](semantic-bindings-v1.md#packsettings-set-3-binding-8-256-bytes).
A persisted user override wins the selected preset override, which wins the
declaration default. Overrides are stored by stable pack ID plus setting ID;
the host validates the selected descriptor's kind, invariant numeric grammar,
range, step, and choice list before supplying the resolved scalars. No renderer
object is exposed to managed code.
When a pack is selected, the retained Config page appends its declared
Boolean, bounded Float/Integer, and Choice controls under **Graphics
Enhancements**. Changing packs replaces only that optional tail; retail's 39
authored Config rows remain unchanged. Numeric controls snap to declared bounds
and steps, preset changes retain explicit user overrides, and a pack change
starts with an empty valid override map for the new stable pack identity.
## Manifest
The authoritative machine-readable schema is
[`plugin-manifest-v1.schema.json`](plugin-manifest-v1.schema.json).
| Field | Meaning |
|---|---|
| `id` | Stable lowercase logical plugin ID. It is persisted and must not be localized or reused. |
| `displayName` | User-visible plugin name. |
| `version` | Dotted `System.Version`-compatible package version. |
| `entryDll` | Safe path beneath the plugin directory to the managed entry DLL. |
| `apiVersion` | General `PluginApi` version. v1 is `1`; this is distinct from `RenderPackApi`. |
| `dependencies` | Optional plugin IDs that must load first. |
| `kinds` | Entry-point kinds. Include `renderPack`; omission means legacy `gameplay` only. A hybrid lists both. |
Install one plugin directory containing this manifest, the entry DLL, its
private managed dependencies, and declared shader assets. Do not redistribute
`AcDream.Plugin.Abstractions.dll` in that directory: type identity is shared
from the host.
## Declaration schema
The C# records in `AcDream.Plugin.Abstractions.Rendering` are the public v1
declaration schema. `RenderPackShaderAbi` publishes the corresponding numeric
SPIR-V set, binding, block-size, and capacity constants. They are intentionally
BCL-only and expose no Vulkan handle.
| Declaration | What the pack supplies | What the host owns |
|---|---|---|
| `RenderPackDescriptor` | Identity/version, highest tier, capabilities, resources, passes, replays, variants, presets, settings, atmosphere policy | Validation, candidate creation, activation and fallback |
| `RenderResourceDeclaration` | Logical ID, portable format, extent, usage, lifetime, estimated bytes | Images/buffers, allocation, barriers, frame-flight retirement |
| `RenderPassDeclaration` | Fixed hook, shader asset keys, semantic inputs, logical resource reads/writes | Render graph order, descriptor layout, pipeline, command recording |
| `SceneReplayDeclaration` | One supported replay semantic, caster flags, 14 views | Resident caster selection and existing batched submissions |
| `PipelineVariantDeclaration` | Base pipeline semantic, shader assets, compatible material flags, inputs | Visibility, mesh/material ownership, fixed renderer state |
| `RenderQualityPreset` | Capability requirements, resource/setting overrides, optional execution hints, and p50/p99 CPU/GPU/VRAM ceilings | Availability, explicit selection and stable-boundary swaps |
| `RenderSettingDeclaration` | Stable ID, kind, default, bounds/choices | Persistence and conditional Display UI |
| `AtmospherePolicyDeclaration` | Ordered sun and selected-light elevation curves plus explicit `activeDayGroup` multipliers | Authored Dereth clock, celestial source, day group, weather and indoor state |
IDs use `^[a-z][a-z0-9._-]*$`, are case-insensitively unique within each
declaration kind, and remain stable across updates. A pack must declare at
least one quality preset and at most 64 settings. The SDK ceiling is 256 MiB pack-owned resident GPU
memory, 16 MiB per SPIR-V asset, 16,384 pixels per absolute image dimension,
256 image layers, and four scene-replay views. A physical device may expose a
lower ceiling or reject a preset whose mandatory capabilities are absent.
For API v1 the host admits optional pack memory from one eighth of the selected
adapter's probed device-local heaps, capped at 256 MiB resident and 512 MiB
transient multisample storage. Presets remain listed with exact limit reasons.
Auto requires asynchronous timestamps and uses Low when Medium cannot fit. At
runtime, Auto alone watches the selected preset's declared inclusive-GPU p99,
pack-added CPU p99, and resident-GPU budgets. If Low remains over any of those
budgets for 180 stable samples, the whole pack fails safely to acdream's
default renderer with the measured and declared limits in the failure reason.
Explicit Low remains selectable and is not silently disabled by the Auto
performance policy.
A Tier-2 directional-shadow elevation curve must resolve to exactly zero at
and below the authored 0-degree horizon. Every declared non-positive point
must therefore have multiplier `0`; if the curve omits an exact 0-degree
point, its first positive point must also be `0` so endpoint clamping or
interpolation cannot manufacture a below-horizon directional shadow. The host still
owns the independent no-selected-light-energy and indoor gates.
The built-in Atmospheric Low preset preserves the complete directional-shadow
caster set (terrain, opaque and alpha-cutout world geometry, and both animated
classes). It reduces cost with two 768 x 768 shadow maps and the ordinary
six-pass, quarter-resolution separable post chain: sun occlusion, sun rays,
bloom downsample, horizontal blur, vertical blur, and filmic composition. It
does not remove a caster class or use the fused post-process hint.
`FusedAtmosphericPostProcess` is an optional external-pack Low-preset execution
hint for the standard atmospheric graph; it is not built-in Low behavior. An
opting-in shader pack implements the PackPass ABI below: the host feeds scene
depth directly to sun rays and asks filmic to evaluate the declared bloom
extraction and separable filter while composing the final image. This reduces
command recording without disabling rays, bloom, or filmic composition. The
host never infers the hint from pack identity; unknown hints, non-Low use, and
incomplete standard graphs fail validation.
`MultiviewDirectionalShadowCascades` is a separate explicit Low-preset
execution hint. The opting-in pack must implement three multiview caster
variants. The host records one layered directional-depth
pass with view mask `0b11`; `gl_ViewIndex` selects the exact two declared Low
cascade matrices. Terrain, opaque, and alpha-cutout commands retain their
ordinary pipeline, transform, cull, and cutout semantics. The preset must require
`MultiviewDirectionalShadowCascades`; unsupported hardware makes that Low preset
unavailable before allocation. A zero hint retains ordinary per-cascade passes.
Resources are declared in execution order: a pass cannot read a pack resource
before an earlier pass writes it, and one pass cannot read and write the same
resource. `WorldColor`, `SceneDepth`, and other renderer semantics are not pack
resources and are named in `SemanticInputs` instead. API v1 exposes four
sampled pass-input slots; buffers, `StructuredData`, and storage resources are
reserved enum values and are rejected until a public binding contract exists.
Colour image arrays are likewise reserved; v1 arrays are directional-depth
maps. One declared pass writes at most one attachment. Only `ToneMap` and
`AfterToneMapBeforePrivateViewports` may write directly to the host surface
without naming a pack resource.
Every semantic input implies its capability and the descriptor must list that
capability as required: world colour, scene depth/normals, authored sun/selected-
celestial/day/weather facts, animation transforms, and directional maps cannot
be treated as
optional after a pass unconditionally declares them.
## Lifecycle and versioning
- Discovery calls `Register` but does not open assets or allocate GPU objects.
- Installing a pack never selects it. The user selects a pack ID, version, and
preset; `acdream default (retail-faithful)` is always available.
- The client validates every declaration and selected asset, builds the full
candidate beside the active retail graph, then swaps at a frame boundary.
- Dispose the registration handle to withdraw the descriptor. The host also
withdraws every handle before unloading its collectible plugin context.
- `PluginApi` versions the general managed plugin ABI. `RenderPackApi` versions
these graphics declarations. Additive enum/record support stays compatible;
a breaking contract requires a new render-pack API version and explicit
compatibility path.
- `RenderPackShaderAbi.ShaderAbiVersion` separately versions the numeric
SPIR-V interface (set/binding numbers and std140 block layouts) declarations
are validated against — distinct from `RenderPackApi`/`PluginApi`. Campaign
VM VM6 shipped v2: `AtmosphericFrame` (set 3, binding 5) grew additively
from 160 to 192 bytes (see `docs/render-packs/semantic-bindings-v1.md`'s
"ABI v2 (additive)" section). `RenderPackSpirvValidator` accepts both the
v1 and v2 shapes, so shader assets compiled before a version bump — the
external sample packs among them — never need a rebuild for an additive
change.
- Persisted identity is pack ID + pack version + preset ID, never list index.
User-authored setting strings are keyed by the same stable pack identity and
stable setting ID, never declaration or menu index.
"Device recreation" in the v1 SDK means full teardown of the old renderer,
graphics context, and device, followed by construction and capability probing
of a fresh context/device. Retail is authoritative until a fresh pack candidate
validates and activates. The SDK does not promise live recovery of a pack or
renderer after `VK_ERROR_DEVICE_LOST`; that error is terminal to the old device
lifetime.
The complete campaign contract, budgets, and non-goals remain in
[`2026-08-21-atmospheric-rendering.md`](../plans/2026-08-21-atmospheric-rendering.md).
The public shader-facing contracts are the
[`semantic binding table`](semantic-bindings-v1.md) and
[`compatibility/failure guide`](compatibility-and-failure-v1.md).

View file

@ -1,134 +0,0 @@
# Render-pack compatibility and failure handling v1
**Campaign phase id:** **TBD**
Compatibility is a declaration and activation result, not a promise inferred
from a GPU brand. The client keeps unsupported packs visible with one exact
reason, refuses to select an unavailable preset, and continues rendering the
authoritative acdream default (retail-faithful) path.
## Author responsibilities
- Declare every mandatory facility in `RequiredCapabilities`. Use
`OptionalCapabilities` only when the pack has a deterministic path that does
not need it.
- Gate each preset independently. Low must remain semantically correct; lower
shadow resolution or reach rather than silently removing trees, monsters,
players, buildings, alpha cutouts, or animated transforms.
- Keep resource estimates conservative and below the preset and 256 MiB SDK
ceilings. The host clamps dimensions and bytes before allocation. Its
optional-pack memory policy admits at most one eighth of the selected
adapter's probed device-local heap, capped at 256 MiB resident and 512 MiB
transient multisample storage; the lower value wins and is printed in an
unavailable-preset reason.
- Use only declared hooks, semantic inputs, resources, scene replays and base
pipeline variants. Pack code receives no arbitrary per-frame callback,
command buffer, gameplay owner, RHI object, or Vulkan handle.
- Treat registration as pure declaration publication. `OpenRead` must return a
new readable stream for the exact requested key and must not retain a world
generation or borrowed frame state.
- Ship SPIR-V words little-endian, four-byte aligned, no larger than 16 MiB per
asset, and compatible with the published v1 semantic binding ABI. Both the
SDK and client validate the binary stage, `main` entry point, descriptor
allowlist, exact uniform/push layouts, and read-only storage contract before
pipeline creation. Vertex and fragment keys are independent logical keys;
the selected candidate copies their blobs and never resolves them through
the host shader directory.
- Declare no more than 64 settings and keep their descriptor order stable. The
set-3/binding-8 shader mapping is positional: a persisted user override wins
the selected-preset override, which wins the declaration default. Boolean
becomes 0/1, Choice becomes its zero-based choice index, numeric strings use
invariant culture, and unused or defensively invalid slots are zero. The
selected descriptor validates every user string against kind, range, step,
and choices before activation.
## Client transaction
1. Discover the manifest and descriptor without opening assets or constructing
GPU objects.
2. Compare required capabilities and preset ceilings with the active physical
device's probed `maxImageDimension2D`, `maxImageArrayLayers`, device-local
heap bytes, and format/timestamp support. An unsupported pack remains
installed and its individual presets remain visible with exact
needed-versus-provided reasons.
3. After explicit selection, validate every referenced asset and shader
interface, then build every resource and pipeline in an isolated candidate.
4. Activate the complete candidate at a stable frame boundary. Until that
point retail keeps rendering.
5. If any step fails, retire the candidate through normal GPU-flight fences,
record one stable diagnostic, select `acdream default`, and do not retry
that pack again during the session.
No half-enabled graph is valid. A missing bloom shader does not leave shadows
active; a failed shadow pipeline does not leave a world-colour intermediate or
stale descriptor alive.
Auto is a logical selector rather than an allocated preset. It requires
asynchronous GPU timestamps, starts at Medium when Medium fits, otherwise
starts and stays at Low, and never promotes beyond the highest contiguous
compatible preset. If Low itself cannot fit, Auto fails safely to Retail and
reports the Low limit that failed.
Runtime Auto decisions use the active preset's declared inclusive-GPU p99,
pack-added CPU p99, and resident-GPU budgets. An over-budget Medium selection
can step down to Low; if Low then remains over any declared limit for 180
stable samples, the host atomically deactivates the complete pack, reports the
measured and budget values, and enters `FailedToRetail` without a retry loop.
This performance fallback is Auto-only. Explicit Low remains selectable when
only timestamp support is missing and is never silently reduced by removing
terrain, trees, buildings, monsters, players, alpha cutouts, or animated
casters. The built-in Low preset instead uses two 768 x 768 shadow maps and an
unfused six-pass, quarter-resolution separable post chain. An ordinary explicit
Low validation, candidate-build, or runtime failure still follows the complete
transactional fallback rules above.
## Diagnostic categories
| Category | Example user-facing reason | Recovery |
|---|---|---|
| Manifest | `plugin.json does not declare the renderPack kind` | Correct/reinstall the package |
| Managed ABI | `apiVersion 2 is unsupported; this SDK supports 1..1` | Use a compatible client or rebuild the pack |
| Pack ABI | `requires render-pack API 2; this client supports 1..1` | Same as above |
| Capability | `requires unsupported capability DirectionalShadowMaps` | Select a supported preset/device or retail |
| Declaration | `Pass 'blur' reads resource 'bloom-a' before it is written` | Correct the descriptor |
| User setting | `user override 'exposure' has invalid Float value '1,5'` | Correct/remove that stable setting-ID override; retail remains active |
| Asset | `asset 'bloom.frag.spv' is not valid SPIR-V` | Rebuild/reinstall the pack |
| Shader interface | `AtmosphericFrame must match ABI v1 (seven members, 160 bytes) or ABI v2 (nine members, 192 bytes)` | Recompile against the v1 or v2 binding table — v2 is additive over v1, so existing v1 modules remain valid and need no rebuild; this fires only when a module's `AtmosphericFrame` block matches neither accepted layout |
| Resource ceiling | `preset 'high' exceeds the pack memory ceiling` | Reduce the preset declaration |
| Auto performance | `Low remained over its declared performance budget for 180 stable samples` | Complete pack falls back to Retail; select explicit Low only after reviewing the measured limits |
| Candidate build | `pipeline creation failed for 'directional-shadow-world-cutout'` | Driver/asset diagnosis; retail for this session |
| Runtime/device | `selected pack failed validation on the fresh device` | Retail on the fresh renderer for this session; no retry loop |
| Removal/update | `selected pack is no longer installed` | Retail, while retaining the notice |
Diagnostics and screenshot metadata record pack ID, pack version, preset ID,
compatibility result and fallback reason. Enhanced screenshots are not retail
parity evidence.
## Update and removal
Pack IDs remain stable across compatible updates; increment `PackVersion` and
manifest `version` together. A preset or setting ID that persists must keep its
meaning. User values are persisted as invariant strings under the selected
pack ID and setting ID, so declaration reordering cannot retarget a value. If
an update removes or changes a persisted setting incompatibly, selection fails
atomically to retail with the unknown/invalid override reason instead of
silently applying it elsewhere. If an update removes the selected preset, the client falls back to a
compatible declared preset only after explicit policy permits it; otherwise it
selects retail. Removing or unloading a pack first withdraws registrations,
then retires GPU-flight resources, then releases the collectible load context.
The built-in atmospheric pack's `sun-shadow-*` setting IDs predate the
selected-celestial source contract. They remain stable persisted identifiers;
their current labels and semantics apply to directional shadows from whichever
authored celestial source the renderer selects.
Reconnect, portal travel, resize and world-generation replacement do not
re-register managed packs. Renderer-owned resources are recreated or retired
within the same generation/fence rules; pack assets never own gameplay,
streaming, collision, or physics lifetime.
For v1, device recreation is not an in-place `VK_ERROR_DEVICE_LOST` recovery
path. The host tears down the complete old renderer, context, and device, then
constructs and probes a new context/device. The default retail renderer remains
authoritative while the selected pack is validated as a fresh candidate; a
failed candidate stays on retail without an automatic retry loop.

View file

@ -1,68 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:acdream:render-pack:plugin-manifest:v1",
"title": "acdream plugin manifest v1",
"description": "Manifest shared by gameplay plugins and declarative render packs. A render pack includes renderPack in kinds.",
"type": "object",
"required": [
"id",
"displayName",
"version",
"entryDll",
"apiVersion"
],
"properties": {
"$schema": {
"type": "string"
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[a-z][a-z0-9._-]*$",
"description": "Stable plugin identity. It is persisted; do not reuse or localize it."
},
"displayName": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^[0-9]+(?:\\.[0-9]+){1,3}$",
"description": "Dotted System.Version-compatible package version."
},
"entryDll": {
"type": "string",
"minLength": 5,
"maxLength": 512,
"pattern": "^(?![A-Za-z]:)(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\]+\\.[dD][lL][lL]$",
"description": "Safe forward-slash relative path to the managed entry assembly."
},
"apiVersion": {
"type": "integer",
"const": 1,
"description": "AcDream.Plugin.Abstractions PluginApi version, not RenderPackApi."
},
"dependencies": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9._-]*$"
},
"default": []
},
"kinds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"enum": ["gameplay", "renderPack"]
},
"default": ["gameplay"],
"description": "Omitting kinds preserves legacy gameplay-plugin behavior. A render pack must explicitly include renderPack."
}
},
"additionalProperties": true
}

View file

@ -1,424 +0,0 @@
# Render-pack shader ABI and semantic bindings v1
**Campaign phase id:** **Campaign AR**
**Render-pack API:** `1`
This is a SPIR-V binary contract over renderer-owned descriptors. It does not
expose Vulkan descriptor sets, descriptor handles, images, buffers, samplers,
command buffers, devices, queues, or fences to managed pack code. A pack only
declares semantic inputs and supplies SPIR-V; the renderer validates the
interface and binds immutable frame data.
## Semantic execution and stable identity
`RenderResourceSemantic`, `RenderPassSemantic`,
`RenderPipelineVariantSemantic`, `RenderQualitySemantic`, and
`RenderSettingSemantic` select renderer-owned execution roles. Pack-owned IDs
remain stable persistence, UI, graph-edge, and diagnostic keys; the executor
never recognizes a role by comparing an ID or asset-name string. Custom
fullscreen declarations retain `Custom` semantics and are executed from their
declared hooks and edges.
Every non-custom semantic is unique within its declaration kind. The complete
Tier-2+ atmospheric executor requires its exact v1 pass/resource/pipeline-
variant roles, hook order, scene replay, and graph edges. A Tier-2 pack may
instead declare the directional-shadow component plus the technical custom
`WorldColor` tone-map copy needed to present the HDR world. That profile still
requires the exact shadow depth resource/pass, five caster/receiver variants,
headline-caster replay, capabilities, settings, and elevation policy; it does
not require bloom, rays, grading, vignette, or volumetric shafts. Any other
partial or malformed semantic graph fails validation even when all pack-owned
IDs remain syntactically valid.
Directional-shadow declarations use
`SelectedCelestialDirectionalLight` together with the required
`AuthoredCelestialDirectionalLight` capability and the descriptor's
`DirectionalShadowLightElevationResponse`. `SunDirection`,
`SunElevationResponse`, and `VolumetricShaftSunElevationResponse` remain the
separate sun-specific atmosphere contract for rays and shafts.
The public v1 point record remains named `SunElevationResponsePoint` for ABI
compatibility; points stored in `DirectionalShadowLightElevationResponse` are
interpreted against the selected celestial light's elevation.
## Fixed descriptor ownership
| Set | Binding | Shader declaration | Owner and use |
|---:|---:|---|---|
| 3 | 5 | `AtmosphericFrame` std140 uniform block | Renderer-owned camera/reconstruction, authored sun/day/weather and frame facts |
| 3 | 6 | `DirectionalShadow` std140 uniform block | Renderer-owned cascade matrices, splits, shadow texture slot, shadow policy, and selected authored celestial direction |
| 3 | 7 | `PackPass` std140 uniform block | Renderer-resolved pass resource slots, output facts and pass-local parameters |
| 3 | 8 | `PackSettings` std140 uniform block | Renderer-resolved declaration-order scalar values for the selected preset |
| 2 | 0 | `sampler2DArray uTextures[]` combined-image-sampler array | Existing global sampled-texture table; index with host-supplied slot IDs and `nonuniformEXT` |
Set 0 remains the renderer's existing storage-buffer set. A declared base
pipeline variant inherits the exact renderer pipeline ABI it specializes; it
does not gain arbitrary set-0 storage access. In particular,
`ShadowCasterTransforms` reuses the renderer's existing per-instance transform
publication rather than publishing a second animation pose.
Set 1 remains the current retail uniform layout at bindings 14. Pack shaders
must not redeclare or alias it. Set 3 is strictly opt-in: retail pipeline
layouts contain only sets 02, and the host creates no set-3 Vulkan object
until a validated pack pipeline is activated. Bindings other than those in the
table are reserved and validation rejects them.
## SPIR-V interface validation
Candidate activation and the standalone authoring validator inspect the
actual SPIR-V binary before any shader module or pipeline is created. Each
asset must expose exactly the declared vertex or fragment stage with entry
point `main`. A fullscreen pass may declare only the sampled table at set 2,
binding 0 when its declaration supplies a sampled semantic/resource input,
plus the role-appropriate set-3 blocks. It may not access renderer-private set
0 or retail set 1. A retained-scene pipeline variant may use only the base
set-0/set-1 bindings documented for that exact semantic role, plus its allowed
set-2/set-3 bindings.
Validation checks descriptor type and count, all set-3 uniform-block member
types, offsets, strides, and total shapes, and any declared push block against
the exact 96-byte retail layout below. Renderer storage buffers inherited by a
variant must be read-only; storage images, arbitrary storage descriptors, and
`OpImageWrite` are forbidden. An absent, malformed, aliased, writable, or
undeclared interface rejects the whole candidate atomically to retail with a
specific reason. Validation never exposes or accepts a Vulkan handle.
The binary member layout is validated against matching host structs. The
checked-in shared render-pack GLSL includes are the byte-offset SSOT; authors
include those definitions rather than maintaining a private copy. The tables
below state the same values for review and tool diagnostics.
### `AtmosphericFrame` — set 3, binding 5, 192 bytes (ABI v2; see below)
```glsl
layout(std140, set = 3, binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunScreen; // @0: uv.xy, resolved ray strength, elevation degrees
vec4 uAtmosphereSunColor; // @16: authored display-space rgb (retail has no linear pipeline), combined ray-policy multiplier
vec4 uAtmosphereViewport; // @32: width, height, 1/width, 1/height
vec4 uAtmosphereWeather; // @48: WeatherKind numeric, intensity, delta seconds, outdoor 0/1
vec4 uAtmosphereSunDirection; // @64: surface-to-sun xyz, authored direction brightness
vec4 uAtmospherePolicy; // @80: day group, group factor, shadow factor, shaft factor
mat4 uAtmosphereInverseViewProjection; // @96
// ABI v2 (Campaign VM VM6) — additive, see below:
vec4 uAtmosphereClockWind; // @160: elapsed seconds, wind mean [0..1], wind gust [0..1], wind direction radians
vec4 uAtmosphereWindAmplitude; // @176: lean amplitude m, branch amplitude m, flutter amplitude m, max canopy height m
};
```
`uAtmosphereSunScreen.xy` uses normalized main-world viewport coordinates. The two
strength fields are host-evaluated authored/policy facts; they do not create a
second sky or weather owner. `uAtmosphereWeather.x` is numerically integral and must be
interpreted with this v1 table, not guessed from colour or time:
| Numeric value | Weather kind |
|---:|---|
| 0 | Clear |
| 1 | Overcast |
| 2 | Rain |
| 3 | Snow |
| 4 | Storm |
All other values are reserved. `uAtmosphereWeather.y` is the transition
intensity in the inclusive range 01.
`uAtmosphereSunDirection.xyz` is normalized and points from a lit surface
toward the authored sun. `uAtmosphereSunScreen.z` is the resolved visible ray
strength; `uAtmosphereSunColor.w` is the combined ray elevation/day-group/
weather policy multiplier before a pass's own declared setting. In
`uAtmospherePolicy`, `.x` is the numerically integral active day group, `.y` is
that group's declared multiplier, `.z` is the declared directional-shadow
elevation factor, and `.w` is the declared volumetric-shaft elevation factor.
Shadow curves interpolate in sine-of-elevation space; shaft curves use
smoothstep interpolation in elevation-degree space. These are exact values
from the selected pack's `AtmospherePolicyDeclaration`, not built-in fallback
curves. An accepted directional-shadow curve resolves to exactly zero at and
below the authored 0-degree horizon; non-positive points must be zero, and a
curve without an exact 0-degree point must make its first positive point zero.
`uAtmosphereInverseViewProjection` reconstructs main-world positions
from scene depth and the normalized viewport coordinates. Matrix convention
and depth range match the shared push-block `viewProjection`.
### ABI v2 (additive) — Campaign VM VM6
`AtmosphericFrame` grew from 160 to 192 bytes by appending the two members
shown above after `uAtmosphereInverseViewProjection`. Nothing before offset
160 moved or changed meaning.
`uAtmosphereClockWind`/`uAtmosphereWindAmplitude` feed the shared
`foliage_wind.glsl` include, which `mesh_atmospheric.vert` and the four
`directional_shadow_world_*` (opaque/cutout, base and multiview) caster
vertex shaders call identically so a displaced leaf's shadow moves with it.
No other pass reads these members.
**Compatibility rule:** the host always allocates and binds the full 192-byte
v2 block (`RenderPackShaderAbi.AtmosphericFrameSizeBytes`), but a v1 shader —
one compiled before this campaign, declaring only the original seven members
— binds and reads correctly against it: a bound range only needs to be at
least as large as the block's declared size, so the shader simply never sees
the appended bytes. `RenderPackSpirvValidator.ValidateAtmosphericFrame`
accepts either the v1 shape (seven members, 160 bytes) or the v2 shape (nine
members, 192 bytes); any other member count is rejected naming both. This is
why the external sample packs under `samples/*/Shaders/*.spv` — whose GLSL
sources are not in this tree and are never recompiled — needed no rebuild for
this change.
### Colour space
The main-world colour target, `uAtmosphereSunColor` (sun-ray input), and any
pack-written sun-ray or volumetric-shaft colour are all **display-space**
(retail's 2013 client has no linear lighting pipeline — its fixed-function
output is gamma-encoded for direct display). A pack that does linear-space
math — bloom thresholding, ACES or another filmic tonemap, luma-weighted
saturation, a contrast pivot — must decode each such input before that math
and encode its final output before writing to the UNORM swapchain, or the
math is operating on the wrong numbers (Campaign VM VM3, closing finding F4 of
the Campaign AR review). The built-in Atmospheric pack's
`acdreamDecodeDisplay`/`acdreamEncodeDisplay` helpers in
`atmospheric_common.glsl` (`pow(c, 2.2)` / `pow(c, 1/2.2)`) are the reference
implementation; 2.2 is the retail-era display-gamma assumption, deliberately
not the sRGB piecewise curve.
### `PackPass` — set 3, binding 7, 64 bytes
```glsl
layout(std140, set = 3, binding = 7) uniform PackPass {
vec4 uPackParams0; // @0
vec4 uPackParams1; // @16
vec4 uPackParams2; // @32
vec4 uPackParams3; // @48
};
```
The active semantic pass defines these sixteen scalar meanings. Unused values
are zero. A pass cannot reinterpret values owned by a different pass. Sampled
pass inputs use logical `textureIndexA` through `textureIndexD` in the shared push block;
binding 7 carries scalar/vector policy and filter parameters, not descriptors.
The optional external-pack Low-preset `FusedAtmosphericPostProcess` execution
hint uses these fixed values. The built-in Low preset does not declare it:
| Semantic pass | Values |
|---|---|
| `SunRays` | `uPackParams1 = (1, logicalMaskWidth, logicalMaskHeight, 0)`; input A is scene depth and the shader reconstructs the declared RGBA8 sun mask before radial integration |
| `BloomDownsample` / both `BloomBlur` passes | Declared for the standard graph but not recorded for this preset; their threshold, knee, strength, offsets, and weights remain authoritative inputs to filmic |
| `FilmicComposite` | `uPackParams1.z = 1`; `uPackParams2 = (bloomStrength, threshold, knee, hasVolumetric)`, `uPackParams3.xy = logicalBloomTexelStep`; inputs A/B/C are world color, sun rays, and optional volumetric shafts, and filmic evaluates the full separable bloom kernel before composition |
Zero flags retain the ordinary six-pass atmospheric graph. The built-in Low
preset uses that zero-flag path; Medium, High, and external packs that do not
opt in never use this fused ABI.
### Multiview directional-shadow cascades
The optional Low-preset `MultiviewDirectionalShadowCascades` execution
hint requires the three `*MultiviewDirectionalShadowCaster` variants and the
matching capability in the Low preset. The host begins one layered depth pass
with `viewMask = 0b11`; each vertex shader indexes `uShadowWorldToClip` with
`gl_ViewIndex`. Commands retain exact order, `BaseInstance`, the shared N.5
world-transform arena, texture index/layer, fixed-function culling, alpha cutoff
`0.05`, and both fitted cascade matrices. With no hint the host records the
ordinary one-pass-per-cascade path. Unsupported hardware makes the hinted preset
unavailable rather than silently selecting an over-budget execution form.
### `DirectionalShadow` — set 3, binding 6, 336 bytes
```glsl
layout(std140, set = 3, binding = 6) uniform DirectionalShadow {
mat4 uShadowWorldToClip[4]; // @0, @64, @128, @192
vec4 uShadowSplitFarMeters; // @256
vec4 uShadowControl; // @272
vec4 uShadowBiasMeters; // @288
uvec4 uShadowTextureAndFlags; // @304
vec4 uShadowLightDirectionAndSource; // @320
} directionalShadow;
```
Field meanings are fixed:
| Field/component | Meaning |
|---|---|
| `uShadowWorldToClip[0..3]` | Texel-stabilized world-to-shadow-clip matrices; only the first `cascadeCount` entries are active |
| `uShadowSplitFarMeters` | Far distance of cascades 03 in camera-eye metres |
| `uShadowControl.x` | Directional shadow strength |
| `uShadowControl.y` | Filter softness |
| `uShadowControl.z` | Maximum shadow reach in metres, clamped to resident data |
| `uShadowControl.w` | Cascade blend width in metres |
| `uShadowBiasMeters.x` | Constant receiver/caster bias in world metres |
| `uShadowBiasMeters.y` | Slope-scaled bias in world metres |
| `uShadowBiasMeters.z` | Normal offset in world metres |
| `uShadowBiasMeters.w` | Caster depth padding in world metres |
| `uShadowTextureAndFlags.x` | Directional-depth array slot in set 2 |
| `uShadowTextureAndFlags.y` | Active cascade count, 14 |
| `uShadowTextureAndFlags.z` | Square shadow-map resolution in pixels |
| `uShadowTextureAndFlags.w` | Flags; bit 0 means directional shadows are valid/enabled; bits 811 carry the fixed receiver PCF radius; remaining v1 bits are reserved and zero |
| `uShadowLightDirectionAndSource.xyz` | Normalized direction from a lit surface toward the one authored celestial body selected for this shadow frame |
| `uShadowLightDirectionAndSource.w` | Numerically integral selected-source kind from the table below |
Selected-source kinds are stable ABI values:
| Numeric value | Selected celestial source |
|---:|---|
| 0 | None / unavailable; the enabled flag must be clear |
| 1 | Authored sun |
| 2 | Dominant authored Dereth moon |
| 3 | Secondary authored Dereth moon |
All other values are reserved. The selected source is a renderer-owned fact
resolved from the current immutable Dereth sky frame. A pack does not identify
sky objects by private index or create a second celestial clock. The host still
publishes only one directional-depth array: sun and moons are alternative
sources for the same bounded cascade work, not simultaneous shadow maps.
When producing a cascade, its zero-based cascade index uses the existing
`uRenderPass` push-constant member. Consumer shaders choose a cascade from the
eye-space distance and split values. No available selected celestial source, a
selected source at or below its accepted horizon, no authored directional
energy, indoors, and portal/login cover clear the enabled bit; shaders must not
sample stale maps when it is zero. Binding 5 remains sun-specific for sun rays
and volumetric shafts. Such passes must not substitute the selected moon
direction for `uAtmosphereSunDirection`; when binding 6 selects a moon they
treat its shadow map as unrelated to sun-shaft occlusion.
## Shared push constants
Every pipeline retains retail's exact shared 96-byte push-constant range. API
v1 does not enlarge it:
```glsl
layout(push_constant) uniform AcdreamPushBlock {
mat4 viewProjection; // byte 0
int drawIdOffset; // byte 64
int lightingMode; // byte 68
int renderPass; // byte 72
int lightDebug; // byte 76
uint textureIndexA; // byte 80
uint textureIndexB; // byte 84
float paramA; // byte 88
float paramB; // byte 92
} acdreamPush;
```
Pack shader source may use the logical aliases `uTextureIndexC` and
`uTextureIndexD`. The host stores their uint slot bits in the existing
`paramA` and `paramB` words, and the shared Vulkan preamble exposes them as
`floatBitsToUint(acdreamPush.paramA)` and
`floatBitsToUint(acdreamPush.paramB)`. This is an exact bit reinterpretation,
not numeric float conversion. Pack pass scalar/vector parameters belong in
binding 7, so the two spare retail words are available for these input slots.
Do not reshape existing fields. A future additive growth requires matching
host/shader layout tests and must remain inside the 128-byte Vulkan guarantee.
A pack pass receives scalar/vector values through binding 7. Base pipeline
variants use `viewProjection`, draw offset, texture slots and existing mode
fields according to that base pipeline's contract.
## PackSettings (set 3, binding 8, 256 bytes)
`RenderSettingDeclaration` values use one fixed declaration-order block:
```glsl
layout(std140, set = 3, binding = 8) uniform PackSettings {
vec4 uPackSettings[16];
};
```
The descriptor may declare at most 64 settings, which the authoring validator
and graphical host both enforce. Setting index `i` is its zero-based position in
`RenderPackDescriptor.Settings`; it maps to
`uPackSettings[i / 4][i % 4]`. Declaration order is therefore shader ABI and
must remain stable within a compatible pack version. The resolved value is one
IEEE-754 float: Boolean is `0.0` or `1.0`, Choice is its zero-based index in
`Choices`, and Integer/Float parse with invariant culture before float
conversion. Integer values are limited to the exactly representable inclusive
range -16,777,216..16,777,216; Float values must remain finite in float32. A
matching persisted user override wins a selected-preset `SettingOverride`,
which wins the declaration default. User values remain invariant strings keyed
by stable pack ID plus setting ID; before candidate activation the host rejects
unknown IDs and values that fail kind, range, step, or choice validation. That
failure retires the complete candidate to retail with an exact reason. The host
zero-initializes the complete block, so unused slots and any value that fails
defensive parsing are `0.0`; ordinary descriptor/selection validation prevents
invalid values from reaching the bind.
Binding 7 remains pass-local host dynamics and filter parameters. It must not
be overloaded with pack settings: doing so would make the same setting occupy
different components in different passes and would prevent one stable public
mapping. This fixed block is the complete v1 contract because it adds no
descriptor handles, storage buffers, per-frame managed callbacks, or
pass-specific setting schemas.
## Logical semantic table
The descriptor must list every semantic the shader reads. Listing a semantic
does not guarantee device support; the corresponding `RenderCapability` must
also be required when the table says so.
| `RenderSemanticInput` | Logical shader value | Source / lifetime | Capability prerequisite |
|---|---|---|---|
| `WorldColor` | Sampled main-world colour slot, excluding retained UI and private viewports | `textureIndexA-D` into set 2; current main-world frame | `MainWorldColorIntermediate` |
| `SceneDepth` | Sampled main-world depth slot plus reconstruction matrix | `textureIndexA-D` and binding 5; current main-world frame | `SceneDepthSampling` |
| `SceneNormals` | Sampled main-world normal slot | `textureIndexA-D` into set 2; current main-world frame | `SceneNormalSampling` |
| `SunDirection` | Normalized authored surface-to-sun direction; no second clock | Binding 5 `uAtmosphereSunDirection`; current immutable world frame | `AuthoredSunDirection` |
| `SelectedCelestialDirectionalLight` | Normalized direction and stable source kind for the one authored sun/moon selected to cast this frame's directional shadows | Binding 6 `uShadowLightDirectionAndSource`; current immutable world/sky frame | `AuthoredCelestialDirectionalLight` |
| `SunScreenPosition` | Authored sun projected for the main-world viewport, plus valid/in-front state | Binding 5; current camera/world frame | `AuthoredSunScreenPosition` |
| `ActiveDayGroup` | AC's categorical group plus descriptor-declared group/elevation multipliers | Binding 5 `uAtmospherePolicy`; current Runtime environment frame | `AuthoredWeather` |
| `Weather` | Numeric `WeatherKind`, intensity and outdoor state | Binding 5 `uAtmosphereWeather`; current Runtime environment frame | `AuthoredWeather` |
| `CameraMatrices` | Main-world view-projection and inverse, or directional cascade transforms required by the hook | Push `viewProjection` + binding 5 inverse; binding 6 for cascades | None beyond the hook's feature capability |
| `ShadowCasterTransforms` | Exact existing per-instance/per-part transforms for retained eligible casters | Inherited base-pipeline set-0 ABI; current retained scene | `AnimatedCasterTransforms` |
| `DirectionalShadowMaps` | Directional-depth table slot, active cascade count, matrices, splits and valid state | Binding 6 plus set 2; current outdoor shadow frame | `DirectionalShadowMaps` |
| `FrameTime` | Monotonic frame delta in seconds; never a gameplay clock | Binding 5 `uAtmosphereWeather.z`; current frame | None |
Pack-declared `ResourceReads` are resolved deterministically to the pass input
slots supplied by the host; v1 exposes up to four sampled inputs through
`textureIndexA-D`. `DirectionalShadowMaps` uses the binding-6 texture slot and
does not consume A-D. Slot assignment first walks sampled-image entries in
`SemanticInputs` declaration order (`WorldColor`, `SceneDepth`, and
`SceneNormals` when present), then sampled `ResourceReads` declaration order.
The first input receives A, then B, C, and D. Duplicate inputs are invalid. The
pack never chooses a global texture-table index. Resource IDs describe graph
edges, not binding numbers. `ResourceWrites` are render targets chosen by the
host and are not simultaneously sampled by the same pass.
`RenderResourceKind.Buffer`, `RenderFormatClass.StructuredData`, and
`RenderResourceUsage.Storage` are reserved for an additive future contract.
They have no public v1 descriptor binding and the v1 authoring validator
rejects them instead of accepting an unbindable graph. V1 image arrays are
reserved for `DirectionalDepth`; ordinary colour intermediates are `Image2D`.
Each pass writes at most one declared attachment. A zero-write pass is valid
only at `ToneMap` or `AfterToneMapBeforePrivateViewports`, where the host-owned
main-world target is implicit.
## Texture-table sampling
Vulkan pack SPIR-V targets the same global table as retail shaders:
```glsl
#extension GL_EXT_nonuniform_qualifier : require
layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];
vec4 sample2D(uint slot, vec2 uv) {
return texture(uTextures[nonuniformEXT(slot)], vec3(uv, 0.0));
}
```
An ordinary 2-D texture is a one-layer array at layer zero. Array resources use
their declared layer. Directional depth may be sampled as ordinary depth and
compared/filtered in shader according to the declared shadow policy; the pack
does not create a private sampler or descriptor. `0xFFFFFFFFu` is the
unassigned texture-slot sentinel and must be checked before sampling an
optional input.
## Hooks and availability
| `RenderPassHook` | Inputs valid at the hook | Output boundary |
|---|---|---|
| `ShadowDepthBeforeWorld` | Camera/selected celestial source/environment, cascade block, retained caster transforms | Declared directional-depth resources only; outdoor gating applies |
| `AtmosphereBeforeToneMap` | HDR world colour when required, depth/normals when required, authored atmosphere and earlier declared resources | HDR pack intermediates; rays/shafts composite here |
| `ToneMap` | HDR world colour and earlier atmosphere resources | Main-world display colour |
| `AfterToneMapBeforePrivateViewports` | Tonemapped main-world colour and declared resources | Main world only; private viewports and retained UI remain outside |
Pass order is the descriptor order within a hook and never moves backward
through this table. Discovery does not bind any of these blocks. Bindings exist
only in the fully validated candidate and retire through normal frame-flight
fences on fallback, resize, portal, reconnect, unload, or the teardown phase of
device recreation. Recreation means a complete renderer/context/device
teardown followed by a fresh context/device; it is not live recovery from
`VK_ERROR_DEVICE_LOST`.

View file

@ -1,13 +1,3 @@
> **CORRECTION 2026-08-23 (#427).** This note assumed retail fogs its sky
> meshes ("the sky dome mesh is at a distance where fog contribution
> dominates"). It does not: `GameSky::Draw @0x00506FF0` disables fixed-
> function fog around the entire sky draw unless an AdminEnvirons fog
> override is active. The dome's horizon colour is the authored texture plus
> the keyframe tint. The `SKY_FOG_FLOOR = 0.2` clamp that this assumption
> produced was removed the same day. Q3's terrain-fog conclusions (authored
> `MinWorldFog/MaxWorldFog` applied directly, no scaling) stand and are now
> what the client does.
# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace)
**Date:** 2026-04-23

View file

@ -31,20 +31,6 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
with the dark-bar sprite.)
> **CORRECTION 2026-08-24 (Campaign CT, CT1 fix round).** The sprite id
> above is WRONG for this element. Campaign CT's live-DAT probe
> (`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`, §2
> "SEALED VERDICT") found the attribute/skill row's actual Highlight
> media is `0x06000F93`, drawn via `gmAttributeUI::UpdateSelection
> @0x0049DEE0`'s `SetState(6)` → `InfoRegion::SetState @0x004F0EE0` on
> the row template `0x10000248` itself. `0x06001397` is real, but it
> belongs to a DIFFERENT mechanism: the spellbook row's separate
> selected-overlay child element (`0x10000342` under prototype
> `0x10000343`, via `UIElement_UIItem::SetSelectedState @0x004E1240`).
> This note's "Button state 6" framing was accidentally right about the
> STATE number but wrong about which sprite that state resolves to on
> this element. CT5 is the owning slice for the fix.
- **Footer flips to:**
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
@ -59,8 +45,7 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
3. [ ] Add "Total Experience (XP):" caption.
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
5. [ ] Row text larger (≈icon height) + rows tighter.
6. [ ] Selection highlight → sprite 0x06000F93 (dark bars), not gold tint. (Corrected 2026-08-24 —
see the CORRECTION note above; the sprite id in this checklist item was originally 0x06001397.)
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
7. [ ] Selected footer title → white.
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).

View file

@ -337,18 +337,44 @@ nothing wrong" surprise, matching the register's existing framing of the
### B.2 — Double-click
**Corrected 2026-08-26:** the original symbol-name search missed the real
mechanism. Retail handles this inside the general
`gmVendorUI::HandleMousePresses @ 0x004C40D0`; it does not require a separately
named `CheckForDoubleClick` function. In the Items-list branch, the retail
double-click condition directly calls `gmVendorUI::BuySingleItem` for the
clicked row. The same function also owns staged Buying/Selling removal and
their `ClientLocal` feedback.
**No dedicated double-click-to-buy mechanism was found for vendor shop
items.** Evidence, not absence-of-search:
**Conclusion:** browse-row double-click-to-buy is verbatim retail behavior.
The acdream binding is a port, not an optional modernization. The previous
absence-of-symbol inference was false and is superseded by the direct function
body.
- `gmVendorUI::ListenToElementMessage` (`pc:204260-204309`, full function
read) dispatches on message id 1 (button click →
`HandleButtonClicks`), 7 (dropdown selection change), `0x2c` (page
change), `0x15` (drop release), and `0x1c` (routes to
`HandleMousePresses` only when `m_itemsUI != 0`) — there is no distinct
"double-click" message id handled at the panel level.
- The base list class `UIElement_ItemList` (every method enumerated via
`docs/research/named-retail/symbols.json`, ~50 symbols) has
`HandleSingleSelection`, `HandleTargetedUseLeftClick`,
`ItemList_SetSelectedItem`, `ItemList_OpenContainer` (for double-clicking
a CONTAINER item specifically — opening it, not buying), but **no
generic double-click handler** and no vendor-specific one either.
- Other retail panels DO have an explicit, separately-named double-click
handler when the mechanism exists — e.g. `gmContractsUI::CheckForDoubleClick`
(`0x00497A10`), `gmPageListUI::CheckForDoubleClick` (`0x00493140`). No
`gmVendorUI::CheckForDoubleClick` or `VendorItemsUI::CheckForDoubleClick`
symbol exists in the 18,366-function named table.
**Conclusion:** retail's confirmed vendor-item interaction model is
single-click-to-select (→ drives the global `ACCWeenieObject::selectedID`,
B.3 below) plus an explicit Buy/Add button press. There is no evidence
retail supports double-click-to-buy on the shop list. The user's
expectation likely carries over from inventory-panel muscle memory
(double-click = use/equip elsewhere in retail) — but the vendor "Items"
list is not that panel. **This is flagged as an open question for the
contract, not resolved unilaterally**: per the project's
no-invented-mechanisms discipline, do not silently add a double-click-buy
shortcut and call it retail-faithful. The retail-faithful, fully-evidenced
fix for "double-click does nothing" is: (a) make single-click meaningfully
select (today it only sets a private field with no visible effect — see
B.3), and (b) make the Buy button actually work. If the user still wants a
double-click shortcut after seeing single-click+Buy work, that is a
deliberate, flagged acdream UX addition on top of retail, not a retail port
— call it out explicitly in the commit/register the way AP-116
(Particle Range) or similar user-directed deviations are recorded.
### B.3 — The quantity slider
@ -709,9 +735,10 @@ concretely unblocked by the one before it; skipping ahead reproduces the
polish, not correctness — the server is authoritative either way) and
file it as a fast follow-up if the user notices the round-trip lag on a
refused purchase.
2. **Double-click — RESOLVED 2026-08-26.** Retail's
`gmVendorUI::HandleMousePresses @ 0x004C40D0` directly buys a browse row on
double-click. Keep this behavior and its staged-row siblings.
2. **Double-click** — no retail mechanism found (B.2). Ask the user
directly whether they want a deliberate acdream-only double-click
shortcut once single-click-select + Buy-button-works is verified live,
rather than assuming yes and inventing behavior.
3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's
design question: fold into `SelectedObjectController` directly (it
already owns the seeding logic, would need a `Func<uint,bool>

View file

@ -1,26 +1,5 @@
# Retail client slash-command registry — complete enumeration + acdream audit
> **CORRECTION 2026-08-21 — the "acdream status" columns below are STALE.**
> They record the state on 2026-08-09, BEFORE slice CH4 landed. Every verb this
> document calls MISSING has since been added and verified present in
> `ChatInputParser.ChannelVerbs`: `cg`, `soc`, `o`, `co-vassals`, `covassal`,
> `c`, `fellows`, `group`, `party`, `vassal`, `ab`, `guild`, `gu`, `ct`,
> `clfg`, `crp`. The DIVERGENT row for `g` was also corrected — acdream now
> maps `/g` to Fellowship, matching retail, confirmed against the live retail
> client on 2026-08-21.
>
> The RETAIL side of this document (the registered verbs, handler addresses and
> channel ids) remains accurate and is still the authority. Only the columns
> describing what acdream does are out of date; verify against
> `ChatInputParser.cs` before trusting them.
>
> **CORRECTION 2026-08-28 — issue #360 is complete.** The full local
> `@allegiance`/`@all`, `@house`/`@hou`, and standalone `@motd` dispatcher
> families are implemented with the named-retail grammar and exact GameAction
> wire layouts in both graphical and headless hosts. The older MISSING status
> cells in §2.5/§2.5b and the priority list are historical.
Date: 2026-08-09
Status: RESEARCH ONLY. No production code was changed.

View file

@ -5,13 +5,6 @@ Research lane D of the settings-track campaign
questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and
**Q6** (what every Gameplay Options tab button does).
> **2026-08-26 implementation addendum:** the report below describes the
> pre-OP8 state and its design choices at that date. acdream has now shipped
> Option C end to end: all 306 installed-DAT rows, retail conflicts/capture,
> and real named `.keymap` Load File / Save As/startup/shutdown persistence.
> See `docs/research/2026-08-26-retail-keyboard-routing-audit.md`; AP-202 is
> retired.
**Report only.** No repo code was changed. Every retail claim below carries
a named symbol + address from the Sept 2013 EoR PDB-paired build. The
PDB/binary pairing was verified first:

View file

@ -199,27 +199,6 @@ parsed at `CreateObject.cs:611-619`) — reused rather than re-defined.
`RuntimeHouseState` raw-field owner), and file the seven line builders
as an ISSUES entry rather than porting them tonight.
### 2026-08-28 owned-house closeout
Issue #413 completed this deliberately deferred builder chain. Direct byte
disassembly of the PDB-paired `acclient.exe` recovered the literals and the
x87-obscured branches:
- buy and rent rows use `HousePaymentList::ComposeText`/`ComposeText2` with
retail's singular/plural fallback (`s`, or `es` after lowercase `s`/`x`);
- normal houses use 2,592,000-second periods and apartments use 7,776,000;
paid/maintenance-free rent advances the next-due row by two periods;
- location is `Location: %.1f%s, %.1f%s`, Y/S-N first and X/W-E second;
apartments emit no location;
- paid and unpaid warnings select `HousePanelTextColor` indices 1 and 2
through the row template's authored font-color palette;
- 0x0227 installs the new rent time and clears paid counts, while 0x0228
replaces the rent list; both rebuild the whole display just as retail does.
`RuntimeHouseStateTests`, `LiveSessionEventRouterTests`, and
`MapHousePanelControllerTests` cover the full synthetic wire/state/UI path.
The canonical Release gate passes 16,315/16,315 on 2026-08-28.
## Wire — GameEventType already has all four ids (corrects the handoff)
The handoff claimed "0x0227/0x0228 absent from the enum". Checked

View file

@ -1,211 +0,0 @@
# Handoff — VTank-class plugin automation milestone
**Written 2026-08-20 for a fresh session picking this up cold.**
You are inheriting a completed requirements-research phase and an unstarted
implementation. Nothing has been built yet. The research is thorough and was
done at `xhigh` effort against primary sources — **read it before designing
anything, and do not re-derive it.** This document tells you where everything
is, what has changed since the research was written, and what to do first.
---
## 1. Start here, in this order
| # | Path | Why |
|---|---|---|
| 1 | [`docs/research/2026-07-29-vtank-plugin-automation-requirements.md`](2026-07-29-vtank-plugin-automation-requirements.md) | **The research.** 384 lines. Full VTank capability inventory, the implied host-API surface, mapping to acdream, and the 5-step milestone. |
| 2 | [`docs/plans/2026-07-29-post-vulkan-work-intake.md`](../plans/2026-07-29-post-vulkan-work-intake.md) | Where the milestone is filed (C-bucket candidate), line 35. |
| 3 | `claude-memory/project_plugin_requirement.md` (see note below) | Why plugins are a day-1 architectural constraint, not a nice-to-have. |
| 4 | `CLAUDE.md` § "Code Structure Rules" rule 3, and § "UI strategy" | The binding constraints on this work (see §6 below). |
Rows 12 are committed to the repo. Paths verified 2026-08-20.
> **`claude-memory/` is a junction, not tracked content.** It resolves in the
> main checkout (`C:\Users\erikn\source\repos\acdream`) but **not inside a git
> worktree**, and `git ls-files claude-memory/` returns nothing. The files
> really live under
> `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\`.
> If you are working in a worktree and a `claude-memory/...` path does not
> resolve, that is why — read it from the main checkout or the real path.
---
## 2. The architectural conclusion — do not relitigate
VTank's meta state machine, expression language and loot-rule engine are
**plugin-land, not host-land.** VTank itself was built on Decal's primitives;
acdream ships the equivalent primitive layer and a VTank-like engine then
becomes an acdream plugin — potentially file-compatible with `.met` / `.nav` /
`.utl`, whose encodings the research already decodes.
The second conclusion: **the K2 headless-bot triad is already the right
substrate.** Synchronous borrowed reads, generation-gated typed commands with
attempt semantics, and an ordered delta stream are exactly what a VTank-class
engine needs, and VTank's 293 ms meta tick maps cleanly onto `Tick`. So this
milestone is largely a bridging and query-surface exercise, not new
architecture.
---
## 3. The five steps (dependency-ordered)
1. **Plugin↔Runtime bridge — the enabler, gates everything else.** Mirror the
K2 triad into BCL-only `Plugin.Abstractions`: per-plugin
`Tick(view, commands)`, generation-gated command groups, ordered event
observer. This upgrades plugins to headless-bot parity.
2. **Entity/property query surface.** Names, classification, weenie type,
physics position on entity snapshots; query verbs; per-object property bags
in the retail key space; ID-request command + ID-arrived event.
3. **Spell/enchantment surface.** Enumerable active enchantments (spell id,
layer, seconds remaining), can-cast prediction, explicit cast-on-target,
cast-outcome events.
4. **Interaction/transaction commands.** use-by-id, apply-on, give, container
take-specific, salvage, vendor buy/sell — over the existing
one-transaction gate, with receipts as events.
5. **Nav/move-to layer.** Point-goal movement with arrival/stuck/off-course
events plus a server-confirmed-arrival variant, follow-entity, jump.
(1) gates all. (2) and (3) are independent once (1) lands. (5) is last and is
**the only genuinely new machinery** — everything else is exposure of state
that already exists Runtime-side.
---
## 4. What changed since the research was written
The research is dated 2026-07-29. Three weeks and several campaigns have
landed since, and **three of its "gap" rows have closed**. Verified today:
| Research said | Reality on 2026-08-20 |
|---|---|
| "vendor = M4 Slices 56 in flight" | **Landed**, user-accepted 2026-08-08. `RuntimeVendorRangeQuery`, `VendorShopItemMaterializer`. The six-slice world-interaction program is COMPLETE. |
| "Fellowship state: not in Runtime views yet" | **Landed.** Campaign FA code-complete 2026-08-12: `RuntimeFellowshipState`, `RuntimeAllegianceState`. |
| (not mentioned — postdates it) | Secure trade shipped 2026-08-14: `RuntimeTradeState`. |
**Net effect: step 4's substrate is materially stronger than when the plan was
written.** Re-read §3 of the research against the live tree before scoping —
its gap table is the one part that has aged.
Still true, verified today:
- `WorldEntitySnapshot` is still exactly four fields —
`(uint Id, uint SourceId, Vector3 Position, Quaternion Rotation)`. The
step-2 gap is real and unchanged.
- `IPluginHost` still exposes only `{ HasUi, Log, State, Events, Selection, Ui }`.
- There is **no** enchantment-enumerating view; the state lives in
`RuntimeCharacterState` / `GameRuntimeGameplayViews`.
- There is **no** point-goal movement primitive. Movement is WASD-intent-shaped.
---
## 5. Every file you will need, verified 2026-08-20
**The plugin API you are extending** (BCL-only — see §6):
```
src/AcDream.Plugin.Abstractions/IPluginHost.cs <- the surface to grow
src/AcDream.Plugin.Abstractions/IGameState.cs
src/AcDream.Plugin.Abstractions/IEvents.cs
src/AcDream.Plugin.Abstractions/ISelectionService.cs
src/AcDream.Plugin.Abstractions/IUiRegistry.cs <- AddMarkupPanel; already a better-typed "Meta Views"
src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
src/AcDream.Plugin.Abstractions/IPluginLogger.cs
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs <- the 4-field snapshot to enrich
```
**The Runtime surface you are mirroring** (all three interfaces are `public`):
```
src/AcDream.Runtime/GameRuntimeViews.cs :259 public interface IGameRuntimeView
src/AcDream.Runtime/GameRuntimeCommands.cs :489 public interface IGameRuntimeCommands
src/AcDream.Runtime/GameRuntimeEvents.cs :100 public interface IRuntimeEventObserver
src/AcDream.Runtime/GameRuntimeGameplayViews.cs gameplay projections incl. enchantments
```
**The pattern to copy** — note `IHeadlessBotPolicy` is `internal` to
`AcDream.Headless`, so you are mirroring the *shape*, not re-exporting the type:
```
src/AcDream.Headless/Policies/HeadlessBotPolicy.cs :8 internal interface IHeadlessBotPolicy
:12 void Tick(IGameRuntimeView, IGameRuntimeCommands)
```
**Runtime gameplay state owners** (what step 24 will expose):
```
src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs vitals/skills/spellbook/enchantments
src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
src/AcDream.Runtime/Gameplay/RuntimeActionState.cs selection/combat/cast intent, transaction gate
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs (new since research)
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
```
**Plugin loading/hosting**:
```
src/AcDream.Core/Plugins/PluginLoader.cs
src/AcDream.Core/Plugins/LoadedPlugin.cs
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
src/AcDream.Plugins.Smoke/SmokePlugin.cs <- the worked example
```
**Domain background** (read on demand — junction, see the note in §1):
```
claude-memory/project_modern_runtime_architecture.md Slice-J ownership truth
claude-memory/project_linux_headless_bots.md Slice K; the BINDING #368 contract
claude-memory/project_interaction_pipeline.md use / pickup / appraisal flow
```
---
## 6. Constraints that bind this work
1. **`AcDream.Plugin.Abstractions` is BCL-only.** No App namespaces, no
Runtime types leaking through, no third-party packages. Plugin gameplay UI
goes through `IUiRegistry.AddMarkupPanel` and nothing else. This is what
keeps plugins insulated from presentation churn — see CLAUDE.md rule 3.
2. **Hosts give Runtime ONE of everything** — the BINDING #368 contract in
`claude-memory/project_linux_headless_bots.md`. Read it before adding a
second anything.
3. **Every C-bucket item gets a brainstormed spec before code** — the intake
doc's own sequencing note, and the roadmap rules in CLAUDE.md. Use
`superpowers:brainstorming`, then `superpowers:writing-plans`.
4. **This is not the active milestone.** M4 "Live in the world" is current.
This is filed as a post-M4 C-bucket candidate. Confirm with the user that
they want it started before doing anything beyond the spec — CLAUDE.md's
one-active-milestone rule exists specifically to prevent this kind of drift.
5. **No workarounds without explicit approval**, and any retail deviation
introduced gets its row in
`docs/architecture/retail-divergence-register.md` in the same commit.
---
## 7. First action
Read the research end to end, then re-verify its §3 gap table against the live
tree (three rows are already stale — see §4). Then bring the user a
brainstormed spec for **step 1 only**. Step 1 gates everything else, it is the
smallest coherent slice, and getting the bridge shape wrong is the one mistake
that would be expensive to undo.
Do not start with the nav layer, however tempting — it is step 5 for a reason.
Route automation needs combat and loot reads to decide when to move.
---
## 8. Provenance
- Research commit: `6077ce4d` — *"docs: VTank requirements research — the
plugin-automation milestone model"*, 2026-07-29, authored Fable 5, `xhigh`.
- Originating session: `local_dce33067-349e-45dd-916f-479bfae6cbc6`, titled
**"graphics"**, branch `claude/git-sync-status-5fb1d2`, worktree
`peaceful-blackburn-5333f0`. The misleading title is why it could not be
found by search — the VTank research was one strand of a session about
something else entirely.
- Related sessions, if the VTank *content* is ever needed rather than the
acdream plan: `local_79a15007` (`repos/metas` — authoring VTank `.af` metaf
scripts) and `local_19297181` (`dereth-workspace` — code consuming
`ILootRuleProcessor` / VirindiTank loot rules).
- The research's own source list — Wayback wiki snapshots plus `metaf`,
`virindi-public-clone`, `ACE.BaseMod`, `vtank-routes`, `vtank-loot-profiles`
— is at the end of the research doc. The live virindi.net wiki is
**unreachable** (self-signed TLS; the HTTP wiki returns a database error),
so use the Wayback snapshots cited there rather than trying the live site.

View file

@ -1,188 +0,0 @@
# acdream chat UI audit — window controllers, input bar, menus, filters, window management
Scope: acdream's own chat UI implementation (window controllers, view models,
input bar, menus, filters, window management). Explicitly OUT of scope per
task boundary: retail's glyph tag system, tag click dispatch, and the text
rendering stack (owned by a parallel audit) — not covered here beyond
incidental mentions needed to explain routing.
All claims below are cited `file.cs:line` against the current worktree
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`).
This document supersedes nothing in `claude-memory/project_chat_digest.md`;
it verifies and extends it against the current code as of 2026-08-21.
---
## 1. Inventory — what exists and what each surface owns
| Surface | File | Owns |
|---|---|---|
| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. |
| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. |
| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. |
| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. |
| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. |
| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). |
| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). |
| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. |
| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. |
| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. |
| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). |
| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. |
**Not in this lane** (owned elsewhere per the task boundary): glyph tag
colouring/click dispatch, `UiText` rendering internals, SpewBox glyph
drawing. Where the input bar or router hands text to the SpewBox
(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is
in-lane; the SpewBox's own rendering is not.
---
## 2. Window management
**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`).
**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window.
**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`).
**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap).
**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`).
**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick.
**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive.
**Known, already-registered divergences (not new findings, listed for completeness):**
- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs.
- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5).
- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up.
---
## 3. Input bar — exactly what `UiField` supports
Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369).
**Supported:**
- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat).
- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word.
- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.**
- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line.
- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`.
- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()``OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior.
- **Max length:** DAT-authored via property `0x1E``UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout.
- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system.
**Missing / gaps found:**
1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard``SilkMouseSource.cs:193``Root.WantsKeyboard``KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue.
2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression.
3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it.
4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most).
---
## 4. Known no-ops and stubs (grepped, cited)
| Site | What's disabled |
|---|---|
| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title <text>` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. |
| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. |
| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). |
| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help <verb>`; execution falls through to server passthrough — issue #361, still OPEN (see §5). |
| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. |
No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration.
---
## 5. Open issues — current code status
| Issue | One-line verdict | Evidence |
|---|---|---|
| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. |
| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. |
| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. |
| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). |
| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). |
| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). |
| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. |
| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). |
| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. |
| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). |
| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. |
| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. |
| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). |
**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit.
---
## 6. Test coverage
**Well covered** (direct, behavior-level tests exist):
- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range.
- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse.
- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively).
- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline.
- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry).
- Colors: `RetailChatColorTableTests.cs`.
**Gaps found — user-visible behaviors with zero automated coverage:**
1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button.
2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing.
3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way.
4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true.
5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets.
---
## 7. Prioritized gap list (most user-visible first)
This is the ordering to plan slices from — judgment calls, not a flat dump.
1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail).
2. **#360`@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing.
3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream.
4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption.
5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone).
6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior.
7. **#361`@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps.
8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix.
9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this.
10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps.
---
## Appendix: files read for this audit
- `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs`
- `src/AcDream.Core/Chat/ChatWindowState.cs`
- `src/AcDream.Core/Chat/ChatLog.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`
- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs`
- `src/AcDream.Runtime/Chat/ClientCommandId.cs`
- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial)
- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted)
- `src/AcDream.App/UI/UiField.cs`
- `src/AcDream.App/UI/UiMenu.cs` (partial)
- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus)
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial)
- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial)
- `src/AcDream.App/Input/InputCaptureSources.cs` (partial)
- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs`
- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount)
- `src/AcDream.App/UI/WindowNames.cs`
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial)
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build)
- `tests/AcDream.App.Tests/UI/UiFieldTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs`
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial)
- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range)
- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191)
- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md`

View file

@ -1,376 +0,0 @@
# acdream text-stack audit: seam for retail glyph-level tagged text
**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`,
`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`).
Goal: determine what it takes to support retail's glyph-level tagged
text (a differently-colored, clickable name inside an otherwise
uniformly-colored chat line). No code changes made.
## 1. Current model — what is a rendered text line?
`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text
widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`).
It has **two** display-line shapes, both single-color:
- **`Line`** — `UiText.cs:50`:
`public readonly record struct Line(string Text, Vector4 Color);`
One string, one `Vector4` color for the WHOLE string. This is what
`LinesProvider` (`UiText.cs:62`, `Func<IReadOnlyList<Line>>`) returns
and what the scrollable multi-line transcript path renders
(`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not
per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed
whole to `ctx.DrawStringDatPass`/`ctx.DrawString`.
- **`TextRun`** — `UiText.cs:55`:
`public readonly record struct TextRun(string Text, Vector4 Color);`
Multiple colored fragments concatenated onto **one** authored line,
fed by `RunsProvider` (`UiText.cs:69`,
`Func<IReadOnlyList<TextRun>>?`) and drawn by `DrawSingleLineRuns`
(`UiText.cs:693-754`). This is real per-run coloring — each run gets
its own `ctx.DrawStringDatPass` call at its own pen X
(`UiText.cs:725-743`) — but it is **only reachable when
`OneLine == true`** (`UiText.cs:506-510`: `if (OneLine &&
RunsProvider is { } runsProvider)`), i.e. the static single-line
label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`:
`c.Transcript.OneLine = false;`), so it can never reach
`DrawSingleLineRuns` — the scrollable multi-line path only ever
reads `LinesProvider`/`Line`.
**Answer to Q1:** color today is per-`Line` in the transcript
(scrollable, multi-line, bottom-pinned/word-wrapped) path, and
per-`TextRun` only in the unrelated static single-line label path
(currently used by exactly one controller — see §5/§6). Chat uses the
former exclusively.
## 2. Where the flattening happens (the key finding)
`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a
structured record: `Sender` (string), `SenderGuid` (uint), `Text`,
`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity
survives as a distinct field all the way through `ChatLog`.
It is destroyed in **two** steps inside `ChatVM`
(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second
step is the point of no return:
**Step A — string composition.** `ChatVM.FormatEntry`
(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into
the message prose, e.g. for `ChatKind.LocalSpeech`
(`ChatVM.cs:269-271`):
```
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
? $"You say, \"{entry.Text}\""
: $"{entry.Sender} says, \"{entry.Text}\"",
```
After this call the sender name is prose inside one `string`; there is
no longer a machine-readable boundary marking where "Name" ends and
"says, ..." begins.
**Step B — metadata drop (the actual point of no return).**
`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the
`FormattedLine` record (`ChatVM.cs:384-388`):
```
public readonly record struct FormattedLine(
string Text,
ChatKind Kind,
CombatLineKind? CombatKind,
uint LogTextType);
```
`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all —
only the composed `Text`, `Kind`, `CombatKind`, and the retail color
key `LogTextType`. Every downstream consumer
(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`,
which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`,
then `ChatTranscriptRenderer.BuildLines`,
`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever
sees the flat `Text` string plus one `LogTextType` per entry.
`ChatTranscriptRenderer.BuildLines` then assigns exactly **one**
`Vector4 currentColor` per entry (resolved once from `LogTextType` at
`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment
of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`):
```
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (string frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
```
**So: the sender's identity (name + guid) is available up through
`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and
is then dropped entirely — not merely flattened, but discarded — by
`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a
`UiText.Line` exists, there is no span boundary, no guid, and (because
word-wrap has already run) not even a guarantee that "the sender name"
is wholly contained within a single rendered `Line` if it happened to
sit at a wrap boundary. Any attempt to recover "where is the name"
downstream of this point would have to regex/string-match the composed
prose back apart — fragile (a message body containing the speaker's
own name, or a name that is a prefix of a common word, breaks it) and
still has no guid to attach for the click action.
## 3. Draw path
**One call per line/run, never per-glyph-color-batch beyond that.**
The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one
`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)`
(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one
color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`,
`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in
that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint`
passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there
is no per-glyph or per-substring color inside a single
`DrawStringDatPass` call.
**Drawing N differently-colored runs on one visual line requires N
separate `DrawStringDatPass` calls, each starting at its own pen X** —
this is exactly the mechanism `DrawSingleLineRuns`
(`UiText.cs:693-754`) already uses: it measures each run's width
(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates
a `penX` (`UiText.cs:731-737`), and issues one
`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color,
isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass
is batched block-wide first (all runs' outlines, then all runs' fills —
`UiText.cs:739-743`, same reasoning as the multi-line block-batching
documented at `UiRenderContext.cs:306-318`) so a per-run outline
doesn't notch an adjacent run's descender.
**Yes, the DAT font path supports starting a draw at an arbitrary X
offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string
text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph
advances for any string/substring — already used for substring
measurement in the selection-highlight code
(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0,
c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x`
(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts
at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`,
`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString`
(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror
the same capability. **Conclusion: the low-level draw primitives
already support everything a run-based transcript line needs — no
renderer/font work is required, only a widget-level model change to
call them N times instead of once.**
## 4. Hit-testing
**No sub-line hit-testing exists today; click/hover route to whole
elements, never to a text span.** `UiRoot.HitTestTopDown`
(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via
`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which
recurses into children and, failing that, calls the virtual
`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a
rectangle containment check) — the granularity is always "some
`UiElement`", never "some substring of a `UiElement`'s text." A
resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp`
(around `UiRoot.cs:994-997`) and bubbles via
`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`,
`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case
(`UiText.cs:809-813`) fires the single `OnClick` delegate for the
WHOLE element — there is no notion of "which run was clicked."
**The pieces needed already exist, just not wired to `Click`.**
`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`)
already converts a local point into a `Pos(line, col)` caret position
using the cached draw geometry (`_lastLines`/`_lastBaseY`/
`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance
lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and
`BitmapFont`) — but it is currently invoked only from the
selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`),
gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript
IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar`
already runs on every mouse-down inside the transcript — it is simply
never asked "which run (if any) covers this `(line, col)`", because
`Line` carries no runs to check against.
**What mapping a click to a run would need:**
1. A per-line list of run boundaries (start col, end col, and a
payload — e.g. sender guid) reaching `UiText` alongside the text,
which does not exist today (`Line` has no such field, see §1/§2).
2. `HitChar`'s existing `(line, col)` result checked against that
list — this is a small, local addition to `UiText`, not a new
hit-test mechanism.
3. A dispatch from "run payload resolved" to an actual action (e.g.
pre-filling a `/tell <name>` in the chat input) — analogous to the
existing `OnClick` delegate, but keyed by run rather than by
element.
No retail-side click semantics (what a name-click does) were
researched here — that is a different agent's lane per the task brief.
## 5. Seam proposal
**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):**
- `AcDream.Core` must not depend on window/GL/backend projects (rule 2)
`ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged
run needs (sender name + guid + explicit text-span boundaries) but
must not know about `Vector4`/GL/rendering.
- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the
composition of "structured entry -> ordered list of colored,
optionally-actionable spans" is exactly the kind of pure formatting
logic `ChatVM` already owns (`ChatVM.FormatEntry`/
`RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning;
it must not reach into `AcDream.App` (GL/rendering) types.
- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where
GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent
hit-testing (`HitChar`, `UiRoot`) live, and must stay there.
**Proposed layering (three seams, one per project boundary):**
1. **`AcDream.UI.Abstractions` (data shape)** — introduce a
run-carrying line shape parallel to (not replacing) `FormattedLine`.
Sketch: `FormattedLine` gains an optional ordered list of spans, or
a new `RichFormattedLine(IReadOnlyList<FormattedRun> Runs, ...)` is
added, where `FormattedRun` is something like
`(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately
NOT carrying a color yet (`AcDream.UI.Abstractions` has no
`System.Numerics`/GL dependency requirement today, but keeping
color resolution in `AcDream.App` mirrors the existing
`RetailChatColorTable`/`ChatTranscriptRenderer` split, where
`ChatVM` supplies `LogTextType`/structured data and
`ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`).
`ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that
returns spans instead of one interpolated string — e.g. split each
`case` into "prefix run" / "sender run" / "suffix run" instead of a
single `$"..."` — and `RecentLinesDetailed` would carry
`entry.Sender`/`entry.SenderGuid` through instead of discarding them
(the §2 fix).
2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines`
(`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller-
shared seam that already resolves `LogTextType -> Vector4` and
word-wraps. It would gain a variant that word-wraps a RUN LIST
instead of a flat string per entry, producing a new "rendered line
with runs" shape (see below) instead of `UiText.Line`. Both
`ChatWindowController` (`ChatWindowController.cs:778`) and
`FloatingChatWindowController` (same shared function, per the class
doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new
builder — this is the one place both chat surfaces already share,
so it is the natural single point of change for chat specifically.
3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual
gap is. `Line` needs an additive `Runs` concept for the
MULTI-LINE (`OneLine == false`) path, not just the existing
`OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add
a parallel `RichLine`) to carry `IReadOnlyList<TextRun>` alongside
or instead of a flat `string Text` + single `Vector4 Color`; change
`DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a
line with runs, do what `DrawSingleLineRuns` already does per-run
(walk runs, accumulate `penX`, call `DrawStringDatPass` per run,
batch all outlines-then-all-fills at the BLOCK level exactly as
`UiText.cs:681-690` already batches across LINES today — extending
that batching one level deeper, across runs within lines, is
mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally
resolve which run (if any) contains the hit `col`, and `OnEvent`'s
`Click` case (`UiText.cs:809-813`) needs a second dispatch path
(run-click, distinct from whole-element `OnClick`) that a controller
(e.g. `ChatWindowController`) can bind to "prefill a tell to this
guid," mirroring how `OnClick` is bound today.
**Existing abstraction that already almost does this:**
`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`)
is the closest precedent — it proves the draw-side mechanics (measure
run, accumulate pen, per-run `DrawStringDatPass`, block-batched
outline) already work and are exercised in production by
`CharacterStatController.BuildSelectedTitleRuns`
(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`,
wired at `CharacterStatController.cs:1680`) for a skill/attribute title
with a colored numeric delta suffix. It is currently scoped to
`OneLine` only and carries no click/actor payload — extending it to
the multi-line/wrapped path and adding a payload field is smaller than
building a new mechanism from scratch. `DatRichText`
(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text,
Vector4 Color)` at `DatRichText.cs:40`, `Compose` at
`DatRichText.cs:52-88`) is a second, partially-overlapping precedent:
it already composes multiple colored segments for a multi-line box,
but it word-wraps EACH segment independently and concatenates the
results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments
that would visually share one wrapped row are NOT joined onto that row
today; it solves "multiple colors across a paragraph's several lines,"
not "multiple colors sharing one rendered row." A tagged-name-in-chat
feature needs the latter (the name and the rest of the sentence share
row 0 of a possibly-multi-row wrapped message), so neither existing
mechanism is a drop-in — both inform the shape of the fix.
## 6. Blast radius
`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files
reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new
UiText()` (full grep list retained below). **If the change is additive**
(new optional `Runs` field/type alongside the existing `Line`, default
behavior unchanged for every caller that keeps returning plain
`Line`s), the blast radius for BEHAVIOR is limited to whichever
controllers opt in (initially: chat only). The blast radius for
BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs`
recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full
list below, grouped by category — every one of these should be
smoke-tested after a `UiText`-internal change even if it doesn't touch
their own code:
- **Chat (the actual feature target):**
`ChatWindowController.cs`, `FloatingChatWindowController.cs`,
`ChatTranscriptRenderer.cs`, `SpewBoxController.cs`
(`src/AcDream.App/UI/SpewBoxController.cs` — retail's other
colored-text-scroll surface, `RetailLogTextType.ClientLocal` per
`ChatVM.cs:159-183`; likely wants the SAME run model eventually since
it renders `LogTextType`-colored lines too).
- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips;
currently plain `Line`s.
- **Appraisal / item & creature reports:** `AppraisalUiController.cs`,
`CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already
render multi-colored informational text (spell names, damage types)
as SEPARATE `Line`s per colored fragment (one color per whole line,
not per run) — a run model could simplify these, or they could stay
as-is if row-granularity coloring already meets retail fidelity
there (not assessed here — out of this audit's scope).
- **Social panels:** `SocialSquelchPageController.cs`,
`SocialAllegiancePageController.cs`,
`SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs`
— friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA
notes these already do per-state color swaps on `UiText`, a
different (not run-based) mechanism.
- **Vendor / trade:** `VendorUiController.cs`,
`SecureTradeUiController.cs`.
- **Combat / spellcasting:** `CombatUiController.cs`,
`SpellcastingUiController.cs`, `EffectsUiController.cs`.
- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`,
`RetailConfirmationDialogView.cs`,
`RetailConfirmationTextInputDialogView.cs`.
- **Character sheet / creation:** `CharacterStatController.cs` (the
existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`,
`CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`,
`CharacterCreationProfessionPage.cs`,
`CharacterCreationHeritagePage.cs`,
`CharacterManagementUiController.cs`.
- **Options / config:** `ConfigOptionsPageController.cs`,
`ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`,
`KeyboardConfigController.cs`.
- **Misc panels:** `InventoryController.cs`, `RadarController.cs`,
`MapPageController.cs`, `HousePageController.cs`,
`LinkStatusUiController.cs`, `VitaeUiController.cs`,
`VitalsController.cs`, `RetailFpsController.cs`,
`SelectedObjectController.cs`, `IndicatorDetailText.cs`,
`ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`,
`CharacterController.cs`, `DatWidgetFactory.cs` (the factory that
builds every `UiText` from LayoutDesc — touches all of the above by
construction).
- **Tests:** `UiTextTests.cs`,
`SocialFellowshipPageControllerTests.cs`,
`SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`,
`DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`,
`VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of
these that assert on `UiText.Line` shape/count would need review if
`Line`'s shape changes (not if a new type is added additively).
**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line`
untouched) keeps the functional blast radius to chat (and optionally
SpewBox) while still requiring the whole `AcDream.App/UI` tree to
rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A
seam that changes `Line`'s existing shape would force a review pass
across every file in the list above.
## What this audit did NOT do
- Did not research retail's own tagged-glyph-run mechanism (separate
agent's lane per the task brief).
- Did not propose or write any code change — `Line`/`TextRun`/`Segment`
shapes above are illustrative sketches for sizing, not a spec.
- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s
existing one-color-per-`Line` approach is already retail-faithful for
their own content (out of scope; flagged only as a blast-radius
member).

View file

@ -1,543 +0,0 @@
# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build)
Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim
retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`.
Binary Ninja's rendering caveats (misleading compare idioms, ~33-char
truncated inline strings) are called out inline wherever they bit this
investigation.
## TL;DR
The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is
**not** the click handler. It is a keyboard text-replacement macro
(`/t `, `/tell `, `reply ` + space → `@tell <LastTeller>,`) wired through
`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a
"character typed" UI broadcast, not a mouse event. It happens to share the
`"@tell %s, "` idea with the real click path but is a separate code path
with separate (looser) text.
The real click path is a generic, polymorphic **tag** system:
```
mouse button up over a UIElement_Text
→ UIElement_Text::MouseUp @ 0x004694F0
→ UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index)
→ GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag)
→ TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE)
TextTag_IIDString::HandleClick @ 0x00478840
→ ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0
→ gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10
(gate: tag m_type == 0x10000001, chat entry not already focused)
→ ChatInterface::StartTell @ 0x004F41F0
(writes "@tell <Name>, " into the entry, focuses it)
```
Player/speaker names in chat are wrapped by the server/client text
formatters in a `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` markup
span (note: closing marker is a **backslash**, `<\Tell>`, not a
forward-slash — confirmed from the raw literal at
`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells,
channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines,
and (per the generic `[%ws] <Tell:IIDString:0:%ws>...` format at
`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every**
chat line that shows a speaker name embeds the same tag, not just tells.
Clicking any of them always opens a **tell**, regardless of which channel
the line came from.
---
## 1. Click → glyph → tag resolution
### 1a. Entry point: `UIElement_Text::MouseUp`
`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in
`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump,
e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail,
reached only when the mouse-up's button id was previously recorded as
mouse-down over this same element (`cond:0`, looked up in
`this->m_mouseDownTable` keyed by the button id `arg4`):
```c
// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD)
if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element
{
uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2);
Glyph var_24;
if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0)
*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4)
Glyph::~Glyph(&var_24);
}
```
`ebp_2`/`edi_2` are the mouse position converted to element-local,
margin-adjusted coordinates a few lines earlier in the same function:
```c
int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this));
int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this));
```
`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from
the UI event system; `m_margL`/`m_margU` are the element's left/top text
margins (`UIElement_Text` struct, `acclient.h:53412-53415`).
**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the
virtual call) is never shown being assigned in the decompiled output — it
is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the
found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field
step is not visible in this rendering. This is exactly the kind of
"misleading compare idiom" the project's BN caveat warns about — flagged
rather than silently assumed. The surrounding evidence (struct layout,
vtable offset match below) makes this the only coherent reading, but it is
not a directly-visible assignment.
### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY`
`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`:
```c
int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/)
{
UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current
int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset
uint32_t line = 0;
GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line
uint32_t lineWidthPx = 0;
GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width
int32_t lineLocalX = (this->m_iScrollableX + arg2)
- UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification
uint32_t glyphIndex = 0;
GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex);
// clamp to end-of-text
return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements);
}
```
Plain-language: convert the click's local (x, y) into a *scrolled* position
by adding back however far the text view has been scrolled; use the
scrolled Y to find which **wrapped display line** was clicked
(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width
(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset
(left/center/right alignment, `UIElement_Text::CalcJustification @
0x00467260`) can be subtracted back out of the X; then walk that line's
glyph advances to find which **character index** the X pixel falls on
(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a
single integer: "the click landed on/before character N of the full
(unwrapped) text buffer."
### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph`
`GlyphList::InqGlyph @ 0x00473430`:
```c
uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/)
{
ListNode<Glyph>* node = this->m_glyphList._head;
if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0;
for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; }
Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag
return 1;
}
```
A straight O(n) linked-list walk (the glyph list is a `List<Glyph>`, not an
array) to the Nth glyph, then a struct copy. The `Glyph` layout
(`acclient.h:45330-45338`):
```c
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph color, baked in at append time (see §4)
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <...> tag span
};
```
`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is
built from raw text (see §1d), and cleared in bulk by
`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any
whose `m_tag` matches the tag being removed — used when a tagged span is
truncated/deleted from the log).
### 1d. Building tags from markup: `TextTagFactory::MakeTag`
Text is appended to a `UIElement_Text` glyph list via
`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<`
(0x3C) as the start of a tag span: it accumulates characters up to the
matching `>` (0x3E) into a string, then calls
`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g.
`"Tell:IIDString:1234:PlayerName"`):
```c
TextTag* TextTagFactory::MakeTag(PStringBase<unsigned short> const* tagBody)
{
// tagBody looks like "TypeName:ShapeName:payload..."
if (!FindChar(tagBody, ':')) return 0;
typeNameStr = substring-before-first-colon;
if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001
if (!FindChar(rest, ':')) return 0;
shapeNameStr = substring-before-second-colon; // e.g. "IIDString"
if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4
switch (shapeId) {
case 1: result = new TextTag_DID(); break;
case 2: result = new TextTag_IID(); break;
case 3: result = new TextTag_IIDEnum(); break;
case 4: result = new TextTag_IIDString(); break;
}
result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001)
result->m_format = shapeId;
result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc.
return result;
}
```
(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`;
the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls
are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The
resulting `TextTag*` is stashed on every `Glyph` inside the span via
`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters
between the tag's `>` and its closing `<\...>`.
**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18`
is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/
EnumMapper resource) name↔id table — the code only proves the *mechanism*,
not the full roster of type-name strings it accepts. We confirmed "Tell"
(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see
§ "Other tag types" below) from literal format strings elsewhere in the
binary, but did not independently dump the table itself.
### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick`
The `TextTag` vtable layout, read straight from the four subclass vtable
dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`):
| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` |
|---|---|---|---|---|---|
| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ |
| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared |
| 0x08 | `ParseStartTag` | own | own | own | own |
| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared |
| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared |
| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** |
| 0x18 | `BuildStartTagData` | own | own | own | own |
`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s
`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)`
— a virtual call, i.e. this is dispatched **per concrete tag subclass**,
not a single hardcoded action.
---
## 2. What fires on click: generic dispatch, not hardcoded to tells
All four subclasses' `HandleClick` do the same shape of thing — forward to
a global "notice" (AC's internal pub/sub event system) carrying the tag's
type + payload, nothing else:
```c
// TextTag_DID::HandleClick @ 0x00478740
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0
// TextTag_IID::HandleClick @ 0x00478E80
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720
// TextTag_IIDEnum::HandleClick @ 0x00478B40
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770
// TextTag_IIDString::HandleClick @ 0x00478840
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0
```
So the dispatch **is** generic — any listener can register for any of the
four notices and react to any `m_type`. In this build, though, only ONE of
the four notices has an actual (non-stub) listener anywhere in the client:
- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real,
wired to chat's click-to-tell (see §3).
- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared
`__pure` — a pure-virtual stub, no base behavior, and no override for it
was found anywhere in this pass.
- No function definition for a real `RecvNotice_TextTag_DIDClick` or
`RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C
file either — every other hit for those names is vtable-slot noise (the
decompiler filling unresolved thunk slots with neighboring symbol names;
cross-checked, none are real function bodies with those signatures).
**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary
`m_type` values × arbitrary listeners), but in this Sept 2013 build only
the chat-log "clickable speaker name → start a tell" feature is actually
wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist,
parse, and would dispatch correctly if clicked, but nothing in the client
reacts to their click notices — **UNKNOWN whether item links / URLs /
coordinates use these shapes in later builds or via server-composed text
we didn't grep for**; no evidence of them was found in this pass.
### Other tag types found (full roster)
| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? |
|---|---|---|---|---|
| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No |
| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No |
| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No |
| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes**`gmMainChatUI` |
Shape-id-to-class mapping is inferred from the `switch(shapeId){case
1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/
`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can
directly read from format strings ("IIDString") is used everywhere the
"Tell" markup appears, which always constructs `TextTag_IIDString`. This
is strong circumstantial evidence, not a direct read of the DAT enum
table — flagged per the "no guessing" rule.
---
## 3. The prefill itself: `ChatInterface::StartTell`
`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the
registered listener for `SendNotice_TextTag_IIDStringClick`:
```c
void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase<unsigned short> const* name)
{
if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0)
ChatInterface::StartTell(this, name);
}
```
Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only
"Tell"-markup spans do anything on click, other `m_type` values on an
`IIDString` tag (if any exist) are silently ignored here; (b) the chat
**entry box must not already have keyboard focus** — if the player is
mid-sentence typing something else, clicking a name in the log does
nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks
`UIElementManager`'s active/focused element against `this->m_chatEntry`).
Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the
parameter list but **never used** by this handler — only the embedded name
string matters.
`ChatInterface::StartTell @ 0x004F41F0`:
```c
void ChatInterface::StartTell(this, PStringBase<unsigned short> const* name)
{
PStringBase<unsigned short> text = Formatted(u"@tell %s, ", name); // note: trailing space after comma
this->m_chatEntry->vtable->Activate();
this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry
CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown
UIElement_Text::SetText(this->m_chatEntry, &text);
UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */);
UIElement_Text::ClearSelection(this->m_chatEntry);
}
```
So, precisely:
- **Text placed**: `"@tell <PlayerName>, "` — literal `@tell`, a space,
the name, a comma, and a **trailing space** (ready to type the message
body immediately).
- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move
keyboard focus into the chat entry field, and
`CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice
whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @
0x00564200`, is what shows/expands the chat entry bar if it was
currently hidden (confirmed as the one non-stub override among many
vtable-slot look-alikes for that notice name).
- **Cursor**: placed at the end of the inserted text (right after the
trailing space), any prior selection cleared.
- Whatever the player had already typed into the box (if it wasn't
focused — see the focus gate above) is **replaced outright**, not
merged or prepended to.
---
## 4. Hover behavior: color is static per-tag-type, not a hover effect
`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does
**no** glyph/tag lookup at all. It only handles active text-selection
dragging (`this->m_bitField & 0x40`) or falls back to the base
`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @
0x00467460` likewise only inspects `this->m_bitField & 5` (an
editable/selectable flag), not glyph tags. **No hover-triggered highlight,
brightening, or cursor-icon change tied to `Glyph::m_tag` was found
anywhere in this pass.**
What *is* real, and is presumably what reads as "the name is green," is a
**static per-glyph color chosen at glyph-construction time**, based on
whether the glyph belongs to an active `0x10000001`-typed tag span. Inside
`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is
opened/continues:
```c
// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none)
if (ebx_1 == 0 || ebx_1->m_type != 0x10000001)
color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text
else
color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text
// ... color is then baked into the new Glyph's m_color field
```
`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct
color fields exist: `RGBAColor m_curFontColor;` and
`RGBAColor m_curTagFontColor;`, set via
`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)`
with **different authored attribute ids**`0x1B` for `m_curFontColor`,
`0x1D` for `m_curTagFontColor` (seen consistently at
`UIElement_Text::AppendTextWithFont @ 0x00469D70` and
`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @
0x00466AC0` treats its 4th argument as an **index into an authored color
array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so
`FontColor` and `TagFontColor` are two independently-authored per-window
color tables (almost certainly LayoutDesc-driven, consistent with this
project's existing DAT-driven-UI findings), and can hold different colors
at the same index. That is the entire "green name" mechanism: **it's
baked into the glyph once, from data, when the text is appended — not
computed or changed on mouse hover.**
`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a
window's layout overrides attribute `0x1D` — chat windows presumably do.
**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA
values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource,
not in code; not dumped in this pass. Cross-reference
`claude-memory/reference_retail_chat_colors.md` for retail chat-color
ground truth already captured via cdb.
---
## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only
`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one
caller in the whole binary: `ChatInterface::HandleTextReplacements @
0x004F50D0`, which tries three "quick reply" expanders in order:
```c
void ChatInterface::HandleTextReplacements(this)
{
if (this->m_chatEntry == 0) return;
if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER
if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch
ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron
}
```
`HandleTextReplacements` is itself called from exactly one place:
`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e.
`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and
the message originating from the chat entry element:
```c
case 0x11: // idMessage == 0x12
if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */
&& arg2->dwParam1 == 0x20)
ChatInterface::HandleTextReplacements(this);
break;
```
Message id `0x12` is confirmed elsewhere as the "character typed"
broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its
non-control-character path with
`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @
0x00469CAF` — `dwParam1` carries the raw character code. So this whole
path only fires **while the player is typing into the chat entry box and
presses the SPACE bar**, and only after typing one of a small set of
recognized prefixes.
`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text,
checks whether it starts with `/` or `@`, and if so, matches the
first-word substring against known shortcut prefixes (`"t"`/`"te"`-style
12 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal
`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized
prefix (leaving anything typed after it in place) with:
```c
gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell
Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string
UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA
```
This is a **different string** from the click path's `"@tell %s, "` (no
trailing space here; `%hs` explicitly narrow-string-formats
`GetLastTellerName`'s `PStringBase<char>*` return, vs. the click path's
already-wide `PStringBase<unsigned short>` name) — a small but real
divergence between the two "start a tell" paths worth preserving if both
get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and
`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical,
sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName`
instead.
**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro
handler only** — triggered by typing a recognized prefix then a space in
the chat entry. It shares the "write `@tell Name,` into the entry" idea
with the click path but is a wholly separate call chain, keyed off "last
person who told me something" global state
(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`,
`SetLastTellerName @ 0x00589500`) rather than the specific name embedded
in the clicked chat line's tag. It is **not** invoked by, and does not
invoke, any part of the click-to-tag chain in §1§3.
A consequence worth flagging for the port: because the click path reads
the name baked into that *specific* chat line's tag, clicking an **old**
"X tells you" line further up the scrollback still starts a tell to X,
even if X is no longer whoever last told you something — whereas the
`/t `+space keyboard shortcut always resolves to the single global
"last teller," which could be a different person by then.
---
## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types")
The `<Tell:IIDString:<iid>:<name>>displayText<\Tell>` span is built by
`sprintf`-style formatting at multiple sites, not just for direct tells.
Representative literals (some inline strings are BN-truncated at ~33
chars, marked `…`):
| Format string (verbatim where fully visible) | Address | Used for |
|---|---|---|
| `"<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received |
| `"<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech |
| `"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat |
| `"[Co-Vassals] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat |
| `"[Allegiance Broadcast] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast |
| `"Your patron <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat |
| `"Your vassal <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat |
| `"Your follower <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat |
| `"[%ws] <Tell:IIDString:0:%ws>%ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) |
Confirms the closing marker is a literal backslash `<\Tell>` in the raw
string data (not the HTML-style `</Tell>` one might assume), and that the
non-direct-tell variants hardcode the `iid` field to `0` (the click
handler ignores `iid` anyway, so this has no functional effect — but it
means the tag's `m_IID` is meaningless/decorative for anything except
direct tells). The gating logic for direct tells
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`,
`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the
tagged, clickable form when the sender's id falls in the player-character
GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside
that range fall back to the plain, non-clickable
`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so
non-player "tells" (system/GM broadcast-as-tell, etc.) are never
clickable.
---
## Open gaps (explicitly not resolved here)
- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name
strings (only "Tell" and, by strong inference, "IIDString" are
confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString`
with category `0x18` would enumerate the rest and settle whether item
links/URLs/coordinates exist as other `m_type` values on the same
`IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead.
- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/
`m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window
specifically — lives in a LayoutDesc DAT resource, not code.
- **Confirmed absence, not a gap**: no hover-only visual/cursor change was
found for tagged glyphs in this build (`MouseMove`,
`GetShouldBeMouseVisible` both checked directly and neither look at
`Glyph::m_tag`).

View file

@ -1,469 +0,0 @@
# Retail chat: how a tagged, coloured player-name run gets composed
Research-only. No source files modified. All claims cite `symbol @ 0xADDRESS`
in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) or
`docs/research/named-retail/acclient.h` (verbatim retail struct defs) unless
otherwise noted. Addresses without an explicit file are in the pseudo-C dump.
## TL;DR — the mechanism in one paragraph
Retail does **not** use `StringInfo`'s two-colour-argument mechanism
(`RecvNotice_DisplayFinalStringInfo`'s `arg3`/`arg4`) to colour the player
name differently from the rest of the sentence. `StringInfo` is only a
**localization/variable-substitution template** (string-table id + named
variables, or a literal override) — it has no colour or tag fields at all.
Instead, the sender's name is delivered as a **literal inline markup tag**,
`<Tell:IIDString:<GUID>:<Name>>Name<\Tell>`, baked directly into the plain
wide-char sentence *before* it is ever handed to the UI. When that sentence
is appended to the chat log, `UIElement_Text`'s glyph-list builder
(`UIElement_Text::InqGlyphs @ 0x00468ea0`) recognizes the `<...>` markup,
asks `TextTagFactory::MakeTag @ 0x00478480` to parse it into a
`TextTag_IIDString` object (GUID + name), and colours **every individual
`Glyph`** inside the tagged span from `m_curTagFontColor` (font-color
property `0x1D`) instead of the line's `m_curFontColor` (property `0x1B`) —
but **only if the tag's type is the "Tell" enum value `0x10000001`**. Each
`Glyph` also carries a `TextTag*` pointer, which is what makes the run
clickable and lets a click resolve back to the right player.
## 1. Where the sender name becomes a tagged run
### 1a. The literal tag text is baked in at message-composition time
Two adjacent handlers on `ClientCommunicationSystem` build the chat line
for incoming speech, and both embed the markup directly via `sprintf`,
*before* any StringInfo/UI code runs:
- **Local/overheard speech**`ClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0`:
```
005714f5 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
005714f5 PStringBase<char>::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag
005714f5 else
00571511 PStringBase<char>::sprintf(&s_NullBuffer_2, "<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n");
```
(full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60`
= `"<Tell:IIDString:%d:%s>%s<\\Tell> says, \"%s\"\n"`, since Binary
Ninja's inline preview truncates at ~33 chars).
- **Direct tell**`ClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0`, same shape:
```
00571880 if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
00571880 PStringBase<char>::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag
00571880 else
0057189c PStringBase<char>::sprintf(&arg5, "<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n");
```
(full literal: `data_7d0ec0 @ 0x007d0ec0` =
`"<Tell:IIDString:%d:%s>%s<\\Tell> tells you, \"%s\"\n"`).
`%d` = `arg4`, the speaker's actual object GUID from the wire message.
`%s` (first) = the speaker's display name (repeated once inside the tag
payload, once again as the visible glyph text after the `>`).
**The `0x50000001..0x6FFFFFFF` GUID-range gate is load-bearing**: only
senders whose object id falls in that range get the clickable/coloured
treatment at all. This is AC1's dynamic-object id range (players and other
non-static weenies); ids outside it (system/NPC broadcast cases handled
elsewhere) fall through to the plain, untagged `"%s says/tells...` format
with no markup and no special colour.
Group/channel broadcasts go through a **separate** builder,
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (called from
`gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590`,
the TurbineChat room-message handler), which always uses `IID:0` in the tag
(no real object id is available/needed there) and prepends the channel
name, e.g. for General chat:
```
005cd93a ebx = 0x1b; // LogTextType = General
005cd93f var_1c_14 = &ChannelSystem::General_GlobalChannelName;
005cd85c sprintf(&s_NullBuffer_2, "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\"");
```
(full literal: `data_7e83e8 @ 0x007e83e8` =
`"[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\""`). The function
returns a `ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole
sprintf'd string), m_doDisplayText }` and the caller passes `m_display` and
`m_ltt` straight into `ClientSystem::AddTextToScroll`. Similar hard-coded
`IID:0` tag formats exist for Fellowship broadcast (`"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""`,
`data_7d0cdc @ 0x007d0cdc`), Co-Vassals (`data_7d0bfc @ 0x007d0bfc`),
Allegiance Broadcast (`data_7d0c30 @ 0x007d0c30`), patron/vassal/follower
tells (`data_7d0d10`, `data_7d0d4c`, `data_7d0ca0`).
**BN-truncation note**: every one of the `sprintf(..., "<Tell:IIDString:...")`
call sites above showed only a `…`-truncated ~33-char preview in the raw
decompiler output. The full strings quoted here were recovered from the
named constant-pool entries (`data_7d0bfc`, `data_7d0c30`, `data_7d0c88`,
`data_7d0ca0`, `data_7d0cdc`, `data_7d0d10`, `data_7d0d4c`, `data_7d0e60`,
`data_7d0ec0`, `data_7e83e8`), which the pseudo-C dump prints verbatim
elsewhere in the file as global `char const data_XXXXXXXX[N] = "...", 0`
declarations — not guessed.
### 1b. The plain sprintf'd string reaches the chat window unmodified
`ClientSystem::AddTextToScroll @ 0x00487fc0` (char* overload) widens to
UTF-16 and tail-calls the wide overload `@ 0x00563c50`, which wraps the
**already-composed** text (markup and all) in a **literal-override**
`StringInfo` and forwards it to the notification bus:
```
00563ee9 StringInfo::SetLiteralValue(&var_890, &m_charbuffer_3 /* full "<Tell:...>...\"" text */, 1);
00563f05 StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1);
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */);
```
This confirms `StringInfo` here is used purely as a **transport wrapper**
around an already-fully-formed literal string — `StringInfo::SetLiteralValue`
sets `m_Override = 1` (literal) so `StringInfo::GetString` later just
returns the wrapped text verbatim; no template/variable substitution
happens for chat lines built this way. (See §4 for why `StringInfo` cannot
itself be the tag carrier.)
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (the
override registered on `ChatInterface`) receives this notice:
```
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc); // arg4 = timestamp StringInfo, colour index 0x0C
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2); // arg3 = the message StringInfo, colour index arg2 (LogTextType)
```
`arg4` (the *second* StringInfo, appended first, at the fixed colour index
`0x0C`) is the **timestamp prefix** (`"HH:MM:SS "`, built a few lines
earlier in `AddTextToScroll` from `PlayerModule::DisplayTimeStamps` +
`wcsftime`), not a channel-name prefix. `arg3` (colour index `arg2` = the
caller-supplied LogTextType) is the **entire rest of the line**, channel
prefix and all — see §3 for why that resolves the "0x0C is grey" question.
### 1c. `AppendStringInfoWithFont` resolves the literal text and hands it to the glyph parser
`UIElement_Text::AppendStringInfoWithFont @ 0x00469de0`:
```
00469df4 UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
00469e09 UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); // line colour, keyed by LogTextType index arg4
00469e1a UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); // tag colour, same index
00469e2a eax_1 = StringInfo::GetString(arg2, &arg3, 0); // resolves literal-override text verbatim
00469e44 UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
```
`m_curFontColor` and `m_curTagFontColor` are named fields on
`UIElement_Text` (`docs/research/named-retail/acclient.h:53408,53410`):
```
struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler
{
...
RGBAColor m_curFontColor;
Font *m_curFontObj;
RGBAColor m_curTagFontColor;
...
};
```
So **before any markup parsing happens**, the widget primes two colours
for the whole append call — the base line colour and a *separate* tag
colour — both looked up via the exact same LogTextType-indexed mechanism
(`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → indexed-array element at [arg4]`).
### 1d. The glyph-list builder recognizes `<...>` and creates the `TextTag`
`UIElement_Text::InqGlyphs @ 0x00468ea0` (the routine `AddText_Internal`
uses to turn the resolved wide string into `Glyph` records) scans
char-by-char; on finding `<` it captures through the matching `>` and
calls the tag factory:
```
00469021 int32_t eax_16 = TextTagFactory::MakeTag(); // parses the whole "<Tell:IIDString:GUID:Name>" span
00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001)
00469084 edx_15 = <UIElement_Text + 0x6a4>; // this->m_curFontColor (RGBAColor field order matches struct above)
00469084 else
0046908a edx_15 = <UIElement_Text + 0x6b8>; // this->m_curTagFontColor
004690c5 Glyph::Glyph(&esp_1[9]); // constructs the glyph with the chosen colour + tag pointer
```
(Note: this function is heavily register/stack-mangled in Binary Ninja's
output — the raw operand forms above are paraphrased from the literal
`esp`/`ecx` chains in the dump, not verbatim BN text, because the BN
pseudo-C here reads as raw stack-slot arithmetic rather than named field
accesses. The two struct-offset destinations (`+0x6a4`, `+0x6b8`) are
`0x14` bytes apart, matching an `RGBAColor` (16 bytes) + `Font*` (4 bytes)
gap between `m_curFontColor` and `m_curTagFontColor` in the struct dump
above — consistent with, but not a byte-for-byte confirmed alias of, those
two named fields.)
`Glyph` itself carries **per-character** colour and tag
(`docs/research/named-retail/acclient.h:45330`):
```
struct __cppobj Glyph
{
unsigned __int16 m_data; // character code
int m_width;
int m_height;
RGBAColor m_color; // per-glyph colour — this is what makes the name a different colour from the rest of the line
Font *m_font;
TextTag *m_tag; // non-null only for glyphs inside a <Tag:...>...<\Tag> span — this is what makes it clickable
};
```
**This is the answer to "who creates the tagged run": `TextTagFactory::MakeTag @ 0x00478480`**,
called from `UIElement_Text::InqGlyphs @ 0x00468ea0` while it walks the
resolved plain-text string looking for `<...>` markers. It is a **markup
parser operating on plain text**, not a StringInfo/variable mechanism.
`TextTagFactory::MakeTag @ 0x00478480` itself:
1. Confirms the captured span starts with `<` and ends with `>`.
2. Splits on the first `:` — the text before it (e.g. `"Tell"`) is looked
up via `EnumMapper::InqEnum(name, 0x18, &m_type)` (a DAT-driven
string→enum table, category `0x18`) to get the numeric tag **type**
(`this->m_type`, e.g. `0x10000001` for `"Tell"`).
3. Splits again on the next `:` — the text between them (e.g.
`"IIDString"`) is looked up the same way to get a small **class**
selector (`var_18`, 14), which a `switch` uses to instantiate the
right `TextTag` subclass:
- `1``TextTag_DID`
- `2``TextTag_IID`
- `3``TextTag_IIDEnum`
- `4``TextTag_IIDString` (jump table `jump_table_478728 @ 0x00478728`, case `4 @ 0x00478617`)
4. Calls the new tag's virtual `ParseStartTag` on the remaining payload
text (everything after the second `:`, i.e. `"<GUID>:<Name>"`).
## 2. Tag payload — what a click needs to address the right player
`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; }`
(`docs/research/named-retail/acclient.h:53960`), with the base class
`struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; }`
(`docs/research/named-retail/acclient.h:45358`).
`TextTag_IIDString::ParseStartTag @ 0x00478910` fills it in:
```
00478946 if (PStringBaseIter_Common<unsigned short>::FindChar(&iter, ':', 0) != 0) // find the FIRST ':' in "GUID:Name"
00478a02 if (PSUtils::is_uint32(leftPart) != 0) // left of ':' must parse as a uint32
004788ad this->m_IID = PStringBase<unsigned short>::to_uint32(&leftPart); // -> numeric object GUID
00478ae2 PStringBase<unsigned short>::operator=(&this->m_string, &rightPart); // -> display name text
```
So the payload is **both** the numeric object id **and** the display name
string, not just one or the other. `TextTag_IIDString::BuildStartTagData @ 0x004788e0`
is the inverse (serializes back to `"0x%08X:%ls"`), confirming the same
two-field shape round-trips.
**Click resolution** (`TextTag_IIDString::HandleClick @ 0x00478840`):
```
0047884c ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
which is picked up by `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10`:
```
004cce1b if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) // arg2 = tag->m_type ("Tell")
004cce2d ChatInterface::StartTell(this, arg4); // arg4 = tag->m_string — starts a /tell using the NAME, not the GUID
```
So in the one client-side consumer we traced, the actual action
(`StartTell`) only uses the **name string**, even though the tag also
carries the numeric GUID. The GUID is transmitted through the notice
(`arg3`) but this handler doesn't consume it — it may be used by other,
untraced `RecvNotice_TextTag_IIDStringClick` overrides (several other UI
classes register the same override — see the vtable-slot list in the
pseudo-C dump around `0x0079e580` onward — only `gmMainChatUI`'s and the
base `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` fallback were read in
this pass). **UNKNOWN — needs a scan of every other `RecvNotice_TextTag_IIDStringClick`
override** if a consumer that actually resolves by GUID matters for
acdream's design (e.g. distinguishing two players who changed names, or a
"select in world" action).
## 3. The `[General]` channel prefix — colour, and resolving the 0x0C puzzle
**Resolved: the apparent "0x0C is grey" contradiction was a mis-identification
on my part before tracing the code, not a real contradiction.** Index `0x0C`
is not the channel-prefix colour — it's hard-coded in
`ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640` (§1b) as the
colour for the **timestamp** StringInfo (`arg4`), which is entirely
separate from the channel-prefixed message text (`arg3`). Confirmed against
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` (see below): index
`0x0C`'s colour **is** `colorGrey` — exactly matching the existing project
note. It's grey because it's the timestamp, not because it's "[General]".
`"[General]"` and the rest of the line (`says, "..."`, including the
embedded name tag's line-colour-before-override) share **one** LogTextType
value for the whole assembled string — set in
`ChatRoomTracker::GetChatFormat @ 0x005cd7c0` (§1a):
```
005cd93a ebx = 0x1b; // General
005cd949 ebx = 0x1c; // Trade
005cd95c ebx = 0x1d; // LFG
005cd96d ebx = 0x1e; // Roleplay
005cd97e ebx = 0x12; // Olthoi
005cd9b8 ebx = 0x20; // Society (all variants)
```
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` builds **one**
LogTextType-indexed colour array (`BaseProperty` at property id `0x1B`,
34 entries, indices `1..0x22`) applied to `this->m_chatLog`. It defaults
every index to `colorGreen @ 0x81c578` and then overrides ~27 of them.
Reading the override sequence address-by-address against the named
`RGBAColor` globals in the constant pool (`0x81c4a8`..`0x81c598`, each
printed with a name, e.g. `class RGBAColor colorGrey = { r=0.824 g=0.824
b=0.784 a=1 }`) gives this full index→colour table:
| LogTextType index | Colour name | RGBA |
|---|---|---|
| 2 | colorWhite | (1, 1, 1, 1) |
| 0x0C | colorGrey | (0.824, 0.824, 0.784, 1) — **timestamp**, not General |
| 3, 0xA, 0x13, 0x1F | colorYellow | (1, 1, 0.247, 1) |
| 4, 0xB | colorTan | (0.824, 0.824, 0.392, 1) |
| 5 | colorBrightPurple | (1, 0.498, 1, 1) |
| 6, 0xF, 0x15 | colorDarkRed | (1, 0.247, 0.247, 1) |
| 7, 0x11 | colorLightBlue | (0.247, 0.749, 1, 1) |
| 8, 9 | colorPink | (1, 0.588, 0.588, 1) |
| 0xD | colorCyan | (0.247, 0.863, 0.863, 1) |
| **0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society)** | **colorBlueGrey** | **(0.706, 0.863, 0.941, 1)** |
| 0x16 | colorLightRed | (0.96, 0.459, 0.447, 1) |
| 0x12 (Olthoi) , 0x21 | colorOrange | (0.933, 0.573, 0.118, 1) |
| 0x1A | colorBrightRed | (1, 0, 0, 1) |
| everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) | colorGreen (default, unoverridden) | (0.5, 1, 0.498, 1) |
So **General/Trade/LFG/Roleplay/Society chat all render in the same pale
blue-grey** (`colorBlueGrey`) as their base line colour — `"[General]"`
and `says, "..."` are the same colour. This cross-checks cleanly against
the project's existing `claude-memory/reference_retail_chat_colors.md`
(same named constants, same addresses, independently dumped live via cdb
on 2026-06-16): its `colorWhite`→LocalSpeech, `colorBrightPurple`(index
5)→Tell, `colorLightRed`(index 0x16)→Combat, and `colorGrey`(index
0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here
exactly. That memory doc's "Channel"→`colorLightBlue` guess (its own text
flags this mapping as an *unverified* nearest-match, "the rare kinds map
to the nearest named color... wasn't traced") is superseded by the exact
trace above: the built-in text channels are `colorBlueGrey`, not
`colorLightBlue` (`colorLightBlue` is indices 7 and 0x11, whose LogTextType
names weren't identified in this pass — **UNKNOWN**, would need the
DAT-driven `LogTextTypeEnumMapper` string table to name every index; see §5).
**Net effect for the screenshot in the prompt**: `"[General] <name> says, ..."` is
**two** colours, not three — the whole line (brackets, "says,", the
quoted message) in `colorBlueGrey`, and the name span in whatever
`m_curTagFontColor` resolves to (see §5) wherever the tag's type is
`"Tell"` (`0x10000001`). If the user's read genuinely showed three
distinguishable hues, the third one is not explained by anything traced in
this pass — flag as **UNKNOWN, possibly a rendering/outline-colour effect
(`m_curOutlineColor`, also a field on `UIElement_Text`, untraced here) or
a visual misread of anti-aliasing against the grey timestamp prefix.**
## 4. Is `StringInfo` the tag carrier?
**No.** Traced its full field set from the constructor/accessor bodies
(`StringInfo::StringInfo @ 0x0042da60`, `::Reset @ 0x0042daf0`,
`::IsValid @ 0x0042cbe0`, `::AddVariable_Int/UInt/Float/String/StringInfo
@ 0x0042dde0-0x0042e7d0`): `m_Override` (0=table-driven / 1=literal /
2=?), `m_stringID`, `m_tableID`, `m_strToken`, `m_LiteralValue`,
`m_strEnglish`, `m_strComment`, and `m_variables` (an
`IntrusiveHashTable<name, StringInfoData*>` for named-variable
substitution into a localized template). None of these are colour, tag,
or link fields — `StringInfo` is purely a **localization envelope**
(string-table id + substitution variables, or a raw literal override via
`SetLiteralValue`). Tagging is applied **after** the envelope is unwrapped:
`StringInfo::GetString` (called inside `AppendStringInfoWithFont @
0x00469de0`, §1c) resolves the final plain wide string, and *that* plain
string is what `UIElement_Text::AddText_Internal`/`InqGlyphs` scans for
`<...>` markup. So the two systems are cleanly separated: StringInfo
answers "what text, and in what language", the glyph-list builder answers
"does any of this text contain clickable/differently-coloured spans".
## 5. Which colour does the tagged name actually use?
**Structurally proven, exact value UNKNOWN.** §1c/1d prove the mechanism:
`UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @
0x00469e1a` looks up property `0x1D` through the *identical*
indexed-array-by-LogTextType path as property `0x1B` (line colour) — see
`UIElement_Text::SetFontColorHelper @ 0x00466ac0`, which does
`InqProperty(propId) → array bounds check (arg4 < count) indexed element
copy`. And `UIElement_Text::InqGlyphs @ 0x00468ea0` proves the *use*: a
glyph gets `m_curTagFontColor` instead of `m_curFontColor` specifically
when its enclosing tag's `m_type == 0x10000001` (the "Tell" tag-name enum
value, resolved via the DAT-driven `EnumMapper` category `0x18` — see §1d
step 2). Tags of any *other* type (e.g. `IIDEnum`-based links used
elsewhere in the client) are still clickable (non-null `Glyph.m_tag`) but
render in the ordinary line colour — the green/special-colour behaviour is
specific to Tell-type name links, not "any markup tag."
What I could **not** find: a second `BuildXxxColorLookupTable`-style
function that populates property `0x1D`'s array the way
`ChatInterface::BuildChatColorLookupTable @ 0x004f31c0` populates `0x1B`
(that function only ever calls `SetPropertyName(&var_18, 0x1b)` once, and
the whole function body — read start to end — only ever writes to that one
array before the final `this->m_chatLog->vtable->SetProperty(&var_18)`).
Two explanations are consistent with what's traced and neither is
confirmed:
- Property `0x1D` is authored directly on the chat-log `UIElement_Text`
widget via its `LayoutDesc` (a per-widget default, not something
`ChatInterface` code builds at runtime) — plausible since
`SetFontColorHelper`'s `InqProperty` call would find *any* property the
widget inherits, not just ones `BuildChatColorLookupTable` wrote.
- A second, unlocated runtime builder populates it elsewhere.
**UNKNOWN — needs either**: (a) a `LayoutDesc`/DAT dump of the chat-log
window's property `0x1D` (or its default RGBAColor), or (b) a live cdb
breakpoint on `UIElement_Text::SetFontColorHelper` with `arg2==0x1D` while
a real Tell-tagged line renders, reading `this->m_curTagFontColor` after
the call returns (same toolchain as `claude-memory/reference_retail_chat_colors.md`'s
`x acclient!color*` / `dd` recipe). The user's screenshot reads it as
green, and AC's clickable-name convention is widely remembered as green,
but that is **not** something this pass proved from decomp — flagging it
as inferred-from-screenshot/prior-knowledge, not decomp-verified.
## Open items / follow-ups
- §2: only one `RecvNotice_TextTag_IIDStringClick` override
(`gmMainChatUI`) was traced for click behaviour; others exist (vtable
slots reference `NoticeHandler::RecvNotice_TextTag_IIDEnumClick` and
`UIElement::MouseHover` as generic fallbacks — worth a second pass if
acdream needs to replicate hover/tooltip behaviour, not just click).
- §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE
(shares colorBlueGrey with the built-in channels), and the seven
unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19,
0x22) are unidentified — the DAT-driven `LogTextTypeEnumMapper` string
table (`struct __cppobj LogTextTypeEnumMapper`, `acclient.h:57333`)
would name them; not pulled in this pass.
- §5: the exact `m_curTagFontColor` RGBA value is unproven from static
decomp alone — see the two follow-up options listed there.
- The `m_format` field on `TextTag` (set from the tag's second colon-split
segment, e.g. `"IIDString"` → the class-selector `1..4`) was read as a
class-shape selector, consistent with the `TextTagFactory::MakeTag`
switch, but its retail name/purpose beyond "which TextTag subclass" was
not otherwise probed.
## RESOLVED: the tag colour is authored, and it is green
The research pass above could only prove the *mechanism* for the tag colour
(property `0x1D`, applied per-glyph when a tag is open and its `m_type` is
`0x10000001`), not its value — `ChatInterface::BuildChatColorLookupTable
@0x004F31C0` builds only the ordinary `0x1B` array, so it correctly flagged the
RGBA as UNKNOWN rather than assuming the green seen in a screenshot.
It is authored in the LayoutDesc, and it measures out of the installed DATs as:
chat window 0x2100006F, transcript element 0x10000011
P0x1B (line colour) [0x00] R=204 G=204 B=204 A=255
P0x1D (tag colour) [0x00] R= 0 G=178 B= 0 A=255 <- the green
Reproduce with:
dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors
Two things worth carrying into the port:
- **The tag colour is per-ELEMENT, not per-LogTextType.** `0x1B` here is a
one-entry array too, so on this element the ordinary colour comes from the
runtime-built chat table while the tag colour comes from the authored
property. A port that files "tag green" into the LogTextType colour table
would be putting it in the wrong place.
- The same `0x1D` green appears on more than one element in this layout, so it
is not unique to the transcript.
`tools/LayoutDump --colors` was added for this measurement and prints the
`0x1B`/`0x1D` arrays of every element in a layout.

View file

@ -1,849 +0,0 @@
# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule
Research-only. No source was modified for this document. All addresses are
from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in
`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and
`docs/research/named-retail/acclient.h` (verbatim retail struct headers).
Every claim below cites `symbol @ 0xADDRESS`; anything not directly
observed in the decompile is marked **UNKNOWN**.
This document explains the mechanism behind the retail behaviour: a
speaker's name inside a chat line renders in a different colour and is
clickable (click → prefill a tell to that person). The mechanism is a
**glyph-level tag model**, not per-line colouring. `UIElement_Text`
(the class backing chat log / most retail text widgets) keeps a list of
`Glyph` structs, one per character, and each `Glyph` optionally points at
a shared, reference-counted `TextTag` object. A **contiguous run of
glyphs sharing the same `TextTag*` pointer** is what gets the special
colour and the click behaviour — there is no separate "run" or "span"
object; identity is pointer equality on `Glyph::m_tag`, discovered by
linear walk every time it matters.
---
## 1. The `TextTag` type family
### 1.1 Struct layout
```
acclient.h:45358
struct __cppobj TextTag : ReferenceCountTemplate<1048576,0>
{
unsigned int m_type;
unsigned int m_format;
};
```
`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is:
```
struct __cppobj ReferenceCountTemplate<1048576,0>
{
ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0
unsigned int m_cRef; // +0x4
};
```
So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`,
`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used
directly by the pseudo-C at several sites cited below (e.g.
`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`,
`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type`
comparison), which cross-checks the struct layout against the header.
`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd:
```
acclient.h:62585
typedef unsigned int TextTagType;
```
No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE|
eTextTag` across `acclient.h` returns only that one typedef line). See
§7 for what this means for the `0x10000001` sentinel.
### 1.2 Four concrete subclasses — what identifies/distinguishes a tag
```
acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; };
acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; };
acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; };
acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; };
```
So a `TextTag` is a small, polymorphic, ref-counted "click payload"
object. Its identity as far as glyphs/runs are concerned is just its
**pointer value** (see §2). Its semantic identity — what clicking it
actually means — is carried by the concrete subclass's extra field(s):
- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference).
- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live
object/creature/player's server-assigned id).
- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary
small-integer qualifier).
- `TextTag_IIDString` — an `IID` plus a `PStringBase<unsigned short>`
(a wide string) — **this is the shape used for a clickable player
name**: `m_IID` is the speaker's object id, `m_string` most plausibly
carries their display name for building the tell command (see §6).
Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`,
`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`,
`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at
`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable''
@ `0x0079e09c`).
**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s
`ParseStartTag`/`BuildStartTagData` slots point at
`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData`
(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct
`TextTag_IID::*` functions. This is almost certainly MSVC identical-code
folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare
32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit
`m_DID.id`, so the linker merged them and the PDB can only attribute the
merged function to one of the two symbols. Treat this as "same code,
shared by both classes," not "IID delegates to DID."
### 1.3 Factory / parsing — `TextTagFactory::MakeTag`
```
acclient_2013_pseudo_c.txt:132871
00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase<unsigned short> const* arg1)
```
Given the text between `<` and `>` (the delimiters are stripped by the
caller — see §3), `MakeTag`:
1. Finds the first `:` in the substring (`FindChar(':')`,
`acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails
and `MakeTag` returns `0` (`return 0;` @ `0x478700`,
`acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end
tag" uses to close a run — see §3.2.**
2. Resolves the substring before the first `:` to an enum value via
`EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`).
3. Finds the *second* `:` and resolves that substring to a second enum
value, also via `EnumMapper::InqEnum`
(`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it
(`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four
concrete subclasses:
```
acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table)
case 1: result = TextTag_DID::TextTag_DID(...)
case 2: result = TextTag_IID::TextTag_IID(...)
case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...)
case 4: result = TextTag_IIDString::TextTag_IIDString(...)
```
4. Stores the two resolved enum values into the new object:
```
acclient_2013_pseudo_c.txt:132981-132982
*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result
*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4)
```
5. Delegates the remainder of the string (after the second `:`) to the
new object's own `ParseStartTag` virtual (via the vtable, at
`acclient_2013_pseudo_c.txt:133008`,
`*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` =
`ParseStartTag` per the layout in §1.1) to consume the
type-specific payload (the `IID`/`DID`/`enum`/`string` fields).
If that fails, the freshly-allocated tag is released
(`ReferenceCountTemplate<1048576,0>::Release(result)` @
`acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this
span.
So the overall wire format `MakeTag` parses is
**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type`
(a semantic category — see §7) and `FORMAT_NAME` selects which concrete
subclass parses `PAYLOAD` (`m_format` doubles as "which of the four
built-in payload shapes this is").
Round-trip confirmation comes from `TextTag::BuildStartTag` (the
inverse operation, used when a tagged region is serialized back to
text — see §2.3):
```
acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0)
eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type
eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format
this->vtable->BuildStartTagData(&...); // subclass-specific payload text
PStringBase<unsigned short>::sprintf(arg2, u"<%ls:%ls%:%ls>");
```
(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls`
is very likely a Binary Ninja string-literal rendering artifact, not a
real extra `%` in the format string; the surrounding logic only ever
supplies three substitutions. **Flagged as uncertain** — resolving it
precisely would need a raw byte dump of the `.rdata` string at its
address rather than BN's decompiled string preview.)
`EnumMapper::InqString`/`InqEnum` both route through a *table id*
argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725,
133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at
`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what
selects which named-enum table (`m_type`'s table vs. individual
subclass fields' tables) to search — see §7 for why we can't yet name
what string maps to `m_type == 0x10000001`.
---
## 2. Attachment model — per-glyph, not per-run, not per-line
### 2.1 `Glyph` struct
```
acclient.h:45330
struct __cppobj Glyph
{
unsigned __int16 m_data; // the character
int m_width;
int m_height;
RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time
Font *m_font;
TextTag *m_tag; // nullable, shared, ref-counted
};
```
`GlyphList` (`acclient.h:45305`) is a doubly-linked `List<Glyph>` plus a
cached `SmartArray<GlyphLine,1>` (line-break layout cache) and some
bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.).
`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList
m_glyphList` (the live/editable text) and a second `GlyphList
m_glTruncate` (used by the truncation machinery — not investigated
further here).
**There is no `TextTag*` field, run/span object, or index range
anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only*
place a tag pointer lives is `Glyph::m_tag`, one per character. A
"tagged run" is purely an emergent property: a maximal sequence of
adjacent glyph list nodes whose `m_tag` fields are pointer-equal.
### 2.2 How code discovers a run boundary
Every place in the decompile that needs to know "does this edit split a
tagged run" or "did the tag change here" does the same thing: walk
adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing
examples:
**On insert**, if the two glyphs immediately either side of the
insertion point shared a tag, the whole tag is stripped (see §3.1 for
why it's the *whole* tag, not just the boundary):
```
acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70)
class ListNode<Glyph>* prev = _current->prev;
if (prev != 0)
{
class TextTag* m_tag = prev->data.m_tag;
if (m_tag == _current->data.m_tag)
GlyphList::RemoveTextTag(this, m_tag);
}
```
**On delete**, the boundary glyph of the doomed range is checked the
same way:
```
acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0)
class TextTag* m_tag = edi->data.m_tag;
if (m_tag != 0)
{
for (class ListNode<Glyph>* i = this_1->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == m_tag)
Glyph::SetTag(i, nullptr);
}
}
```
**On bulk append** (`GlyphList::AddText`), the glyph immediately before
the insertion point is compared to the first glyph being spliced in:
```
acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190)
int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph)
if ((ebx == var_8->data.m_tag && ebx != 0))
{
for (class ListNode<Glyph>* i = this_2->m_glyphList._head; i != 0; i = i->next)
if (i->data.m_tag == ebx)
Glyph::SetTag(i, nullptr);
}
```
**On text serialization** (`GlyphList::InqText`, used to reconstruct
markup text — e.g. what `ChatInterface::TruncateChatLog` reads before
truncating, see §3.3), the same pointer-compare drives when to close
the previous tag's markup and open the new one:
```
acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560)
class TextTag* m_tag = _head->data.m_tag;
if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph
{
if (m_tag_1 != 0)
m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup
if (m_tag != 0)
m_tag->vtable->BuildStartTag(&arg5); // open new run's markup
}
...
m_tag_1 = m_tag; // carried into next iteration
```
So: **a contiguous run is identified only by walking neighbours and
comparing `Glyph::m_tag` pointers; there is no cached run table.**
Every operation that could break a run's contiguity re-derives the
answer by walking.
### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs`
```
acclient_2013_pseudo_c.txt:115983
00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase<unsigned short> const* arg2, class SmartArray<Glyph,1>* arg3)
```
This converts a raw wide string (which may contain embedded
`<TYPE:FORMAT:DATA>` markup) into a flat array of `Glyph`s, one
character at a time. It is called from `UIElement_Text::AddText_Internal`
(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke
point every text-append path funnels through (`AppendText`,
`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the
paste handler, etc. — confirmed by grepping every
`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916,
116994,117008,117026,117043,117106`).
The character loop keeps one local, `ebx_1` (`class TextTag*`), which is
the **currently-open tag while walking characters** — this is NOT a
field on `UIElement_Text`; it's a local in this one function's loop:
- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`),
extracts the substring, and calls
`TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`).
The `<...>` delimiter text itself is **not emitted as glyphs** — the
character cursor (`edi_1`) is advanced past the closing `>` before
glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`).
The result — a new tag pointer, or `0` if `MakeTag` failed to parse —
replaces `ebx_1` for subsequent characters.
- For every ordinary character, the glyph's colour is chosen from `ebx_1`
(see §5 for the exact rule) and a `Glyph` is constructed carrying
`ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that
construction).
**Because `ebx_1` is never explicitly reset to `0` on a "closing
bracket," the only way a tag run ends is for a later `<...>` span to
fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`,
§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits:
```
acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190)
if (this->m_type != 0)
{
EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':'
PStringBase<unsigned short>::sprintf(arg2, u"<\%ls>");
return 1;
}
return 0;
```
i.e. the end-tag markup is a bracketed **type name with no colon**
(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a
Binary Ninja rendering artifact for a literal `/`, i.e. the real string
is most plausibly `"</%ls>"`; **flagged as uncertain**, same caveat as
§1.3 — BN's string preview/escaping for embedded control characters is
not reliably faithful and this should be confirmed with a raw
`.rdata` byte dump before being relied on verbatim). Since that
substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check
fails and it returns `0``ebx_1` becomes `0` → every glyph after that
point is untagged, until the next successfully-parsed `<TYPE:FORMAT:
DATA>` start tag. **Start/end tags are symmetric in markup shape but
asymmetric in mechanism**: a start tag is a successful `MakeTag` parse;
an end tag is nothing more than *any* bracketed text that fails to
parse as one.
---
## 3. Lifetime
### 3.1 Creation, retention, destruction — reference counting
`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at
`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an
object with `m_cRef == 1` (set in the base ctor,
`acclient_2013_pseudo_c.txt:133587-133594`,
`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there,
ownership is **fully distributed across every `Glyph` that points at
it** — there is no separate owning list or registry.
**Adopting a tag reference increments the count.** The parameterized
`Glyph` constructor used by `InqGlyphs` when building a new glyph does
this explicitly:
```
acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90)
*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag
Glyph::SetFont(this_1, arg4);
int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20);
if (eax_5 != 0)
InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++
```
The copy assignment operator (used whenever a glyph is copied — e.g.
splicing the freshly-built `SmartArray<Glyph,1>` from `InqGlyphs` into
the live `List<Glyph>`, or `List<Glyph>::flush`'s per-node teardown)
does the matching release-then-acquire:
```
acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870)
class TextTag* m_tag_1 = this->m_tag;
if (m_tag_1 != 0)
{
if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0)
m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero
this->m_tag = nullptr;
}
class Font* m_font = arg2->m_font;
this->m_font = m_font;
this->m_tag = arg2->m_tag;
if (this->m_tag != 0)
InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag
```
**Releasing decrements the count and self-deletes at zero, via the
destructor**:
```
acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820)
class TextTag* m_tag = this->m_tag;
if (m_tag != 0)
{
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
}
```
**`Glyph::SetTag` is the exception to note carefully** — it releases the
*old* tag (decrement, free at zero) but does **not** increment the
refcount of the incoming tag:
```
acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920)
void Glyph::SetTag(class Glyph* this, class TextTag* arg2)
{
class TextTag* m_tag = this->m_tag;
if (m_tag == 0)
{
this->m_tag = arg2;
return;
}
if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0)
m_tag->vtable->__vecDelDtor(1);
this->m_tag = nullptr;
this->m_tag = arg2;
}
```
Every call site of `Glyph::SetTag` actually observed in this decompile
passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical
pattern at `acclient_2013_pseudo_c.txt:126830`,
`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as
"sever this glyph's reference to whatever tag it has" — a porting
engineer must not assume the general two-argument form is
refcount-safe for a non-null argument; if a call site with a non-null
tag is ever found, it must AddRef beforehand, mirroring the
constructor/`operator=` pattern above.
### 3.2 Whole-tag invalidation on any edit that could split a run
This is the single most important porting gotcha in this document.
None of the three "does this edit touch a tag boundary" checks in §2.2
try to *split* a run in two. All of them, on detecting that an edit
would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every
glyph in the entire `GlyphList` that shares that tag pointer** — not
just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`:
```
acclient_2013_pseudo_c.txt:126822-126833
void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2)
{
if (arg2 != 0)
{
for (class ListNode<Glyph>* i = this->m_glyphList._head; i != 0; i = i->next)
{
if (i->data.m_tag == arg2)
Glyph::SetTag(i, nullptr);
}
}
}
```
`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function
when it detects a would-be-split. `GlyphList::Delete` and
`GlyphList::AddText` inline the identical "walk the whole list, clear
every glyph sharing this tag" loop rather than calling
`RemoveTextTag` directly, but the effect is the same. **The retail
behaviour is: any edit that would leave a discontiguous run under one
tag pointer instead destroys the tag for the ENTIRE list, not just the
disturbed portion.** A tagged player name that gets partially edited or
partially deleted loses its colour/clickability everywhere it appears
in that `GlyphList`, not just at the edit site.
### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog`
```
acclient_2013_pseudo_c.txt:247098
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
```
This reads the chat log's current text length (via
`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2)
and, if it exceeds the cap (`arg2`), calls:
```
acclient_2013_pseudo_c.txt:247148,247178
UIElement_Text::BeheadText(this->m_chatLog, N, 1);
```
`BeheadText` is a thin wrapper:
```
acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970)
void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3)
{
UIElement_Text::DeleteSection(this, 0, arg2, arg3);
}
```
which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt:
116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the
exact same generic deletion path used for any other text edit** (typed
backspace, cut, selection delete). There is no special-cased "truncate
the chat log" tag handling. Consequences, following directly from §3.1
and §3.2:
- A `TextTag` whose glyphs are entirely scrolled off is destroyed the
ordinary way: `GlyphList::Delete` walks the doomed range, finds the
shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it
(§3.2), decrementing to zero and freeing it.
- A `TextTag` whose glyphs are only **partially** scrolled off (the
truncation boundary falls inside a tagged name) has the tag stripped
from **all** its glyphs, including the ones that remain visible — per
§3.2's whole-list behaviour. The surviving remnant of a truncated
tagged name renders and behaves as plain untagged text. This is a
concrete, verified retail behaviour, not a hypothesis — it falls
directly out of `GlyphList::Delete`'s implementation, which
`BeheadText`/`TruncateChatLog` invoke with no special-casing.
---
## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour)
### 4.1 `UIElement_Text`'s "current" state fields
```
acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order)
RGBAColor m_curFontColor; // used for the property "0x1b" value
Font *m_curFontObj; // used for the property "0x1a" value
RGBAColor m_curTagFontColor; // used for the property "0x1d" value
unsigned int m_curOutlineColor;
```
The field ORDER in the header (`m_curFontColor`, `m_curFontObj`,
`m_curTagFontColor` back to back) matches the raw offset arithmetic seen
at the glyph-construction site in `InqGlyphs`
(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets
`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a
`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*`
pointer in between) — this cross-check confirms the struct layout
against the pseudo-C's raw pointer math.
### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont`
```
acclient_2013_pseudo_c.txt:117031-117048
00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4)
{
UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4);
UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4);
...
UIElement_Text::AddText_Internal(this, m_charbuffer, 3);
...
}
```
Both colour properties are refreshed from the **same caller-supplied
index**, `arg4`, immediately before the string is appended
(`AddText_Internal``InqGlyphs`, §2.3). The same three-call pattern
(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append
entry point that carries a colour index:
`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at
`acclient_2013_pseudo_c.txt:115213-115222`, the string-download
completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index
taken from the queued download's own stored index,
`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at
`acclient_2013_pseudo_c.txt:117546-117548`.
### 4.3 What `SetFontColorHelper` actually does — an indexed property array
```
acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0)
void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/)
```
Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`,
`0xf4`, `0x98` is not named by the PDB — see caveat below):
1. `this->vtable->InqProperty(propId, &var_10)` — looks up the
UIElement's own authored property (`0x1b` or `0x1d`) via its normal
`UIElement` property mechanism, i.e. this is a
**LayoutDesc/DAT-authored per-element property**, not global engine
state.
2. If found, the returned property object is treated as an *indexed
collection*: a virtual call through vtable offset `+0xf0`
(`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the
collection's element count into `arg2`; a bounds check
`if (arg4 < arg2)`; then a virtual call through `+0xf4`
(`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the
sub-property at index `arg4`; then a virtual call through `+0x98`
(`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an
`RGBAColor` from that sub-property into the caller's `arg3` output.
3. If any step fails (property absent, index out of range), `arg3` (the
caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched —
i.e. it retains whatever colour it already held from a previous
append.
**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98`
virtual calls (they're dispatched through a generic `BaseProperty`-family
vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable +
offset))(...)` calls). The functional interpretation above
("count / get-at-index / get-color") is inferred from the argument
shapes and control flow (an `arg4 < count` bounds check immediately
followed by an index-parameterized fetch), not from a symbol. Treat the
step-by-step mechanics as **probable, not certain** — the porting-load-
bearing fact that IS certain is the *outcome*: property `0x1b` and
property `0x1d` are each an array of colours on the `UIElement_Text`,
indexed by the same `arg4` the caller supplies, and `SetFontColorHelper`
resolves one colour from each array into `m_curFontColor` /
`m_curTagFontColor` respectively before the string is walked into
glyphs. This lines up with `claude-memory/project_chat_digest.md`'s
`LogTextType colors` note — `arg4` is almost certainly the `LogTextType`
of the message being appended (a per-message-category colour index),
and property `0x1d` is a **parallel, per-category array of "tag"
colours** — i.e. retail authors one link/tag colour per chat category,
not one global link colour.
### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate
Back in `UIElement_Text::InqGlyphs`, per character, the code picks
between the two "current" colours based on whether a tag is open **and**
that tag's `m_type` equals a specific sentinel:
```
acclient_2013_pseudo_c.txt:116150-116162
void* edx_15;
void* esi_6;
if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001))
{
esi_6 = esp_1[6]; // this (UIElement_Text*)
edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor
}
else
{
esi_6 = esp_1[6];
edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor
}
```
(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.)
`edx_15` is then passed straight into the parameterized `Glyph`
constructor as the colour source (`acclient_2013_pseudo_c.txt:116173-
116180`). So, precisely:
- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property
`0x1b`'s indexed value).
- Open tag, but its `m_type != 0x10000001` → **still**
`m_curFontColor`. Not every tag type gets the special colour.
- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor`
(property `0x1d`'s indexed value).
**This is the exact rule the task asked for**: property `0x1b` is the
default/base colour used for all untagged text and for any tag whose
type isn't the specially-recognized one; property `0x1d` is used only
for glyphs inside a tag of that one recognized type, and both are
selected from the same caller-supplied colour-category index. See §7
for what is and is not known about what `0x10000001` names.
### 4.5 "Currently open tag" state
There is **no persistent "currently open tag" field on `UIElement_Text`**
`m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour
palette currently in effect for this append call* (refreshed once per
`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT
properties), not per-tag state. The actual "is a tag open right now,
and which one" state during the character walk is the **local variable
`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not
outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call
starts fresh with no tag open, and the markup embedded in that call's
own string is what opens/closes tags within it.
---
## 5. Click dispatch
Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2)
forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which —
per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is
a broadcast notice any registered `NoticeHandler` (e.g. the chat/social
UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual:
```
acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740)
ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id);
acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80)
ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID);
acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40)
ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum);
acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840)
ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);
```
matching the `NoticeHandler` vtable slots:
```
acclient.h:30237-30240
void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>);
void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int);
void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase<unsigned short> *);
```
This strongly supports the observed behaviour ("clicking a speaker's
name in chat prefills a tell to them"): a chat name is plausibly tagged
`TextTag_IIDString`, carrying the speaker's `IID` (their in-world
object id — the correct addressee for a `/t` tell) and `m_string`
(plausibly the speaker's display name — needed because the retail tell
command syntax is name-based, not id-based). Clicking dispatches
`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and
whichever `NoticeHandler` owns the chat input box (this document did
not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_
IIDStringClick` overrides across the UI classes to find which panel
consumes it and confirm it prefills `/t "name" `**) reacts by loading
that into the input field.
**Not traced from HandleClick backward to the mouse-hit-test that finds
"which glyph, hence which tag, is under the cursor."** No literal
`->vtable->HandleClick(...)` call site was found via text grep (it's an
indirect vtable call, invisible to a literal-string search); locating
the exact hit-test function that resolves a click's screen position to
a glyph index and reads that glyph's `m_tag` was out of scope for this
pass. **UNKNOWN — needs a targeted search for the `UIElement_Text`
mouse-down handler** (candidates: something built on
`GlyphList::FindPosFromLineAndPixels` @
`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve
screen pixels to a glyph index and is a very likely component of that
path, but the actual click-to-`HandleClick` wiring was not confirmed).
---
## 6. Summary — the model to port
1. **Data model**: `TextTag` is a small polymorphic ref-counted object
(`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/
`string` in various combinations). `Glyph` carries an OPTIONAL
`TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the
glyph level stores tag/run information.
2. **Attachment**: identity of a "run" is pointer equality on
consecutive glyphs' `m_tag`. No cached run table exists; every
consumer (insert-boundary check, delete-boundary check, append-
boundary check, text-serialization) re-derives it by walking
neighbours.
3. **Markup**: `<TYPE:FORMAT:DATA>` opens a tag (parsed by
`TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one
of 4 concrete classes); any bracketed text that fails to parse (in
particular the literal `<TYPE>`-shaped close marker emitted by
`TextTag::BuildEndTag`) closes the currently-open tag. The delimiter
text itself is never rendered as glyphs.
4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference
(construction, copy-assignment) increments; releasing (destruction,
explicit clear, reassignment) decrements and self-deletes at zero.
**Any edit that would split a tagged run instead strips the tag from
every glyph in the WHOLE `GlyphList` that shares it** — there is no
run-splitting. Chat-log truncation (`TruncateChatLog`
`BeheadText``DeleteSection``GlyphList::Delete`) is not
special-cased; it goes through this exact same path, so a tagged
name straddling the truncation boundary loses its tag entirely, even
on the surviving portion.
5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored,
index-selected colour arrays — property `0x1b` (base/default) and
property `0x1d` (tag colour) — refreshed from the same caller
colour-category index at the top of every append call. A glyph gets
the `0x1d` colour only if a tag is open AND that tag's `m_type`
equals the sentinel `0x10000001`; otherwise it gets the `0x1b`
colour regardless of whether some *other* kind of tag is open.
6. **Click**: each concrete `TextTag` subclass's `HandleClick`
broadcasts a `NoticeHandler`-family notice
(`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some
listener elsewhere (not traced in this pass) reacts to populate chat
input, open a character sheet, etc., depending on subclass/`m_type`.
---
## 7. Open questions / explicitly unresolved
- **What symbolic name does `TextTag::m_type == 0x10000001` correspond
to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is
a bare typedef). The literal `0x10000001` recurs pervasively
elsewhere in the pseudo-C for apparently unrelated purposes (dialog
IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments,
keymap IDs — see the broad grep hits at
`acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`),
which suggests it's a low, sequential "category 1" id reused across
several small per-subsystem enums rather than one global constant
with a single meaning — i.e. seeing the same literal elsewhere is
**not** evidence about what it means for `TextTag`. Both
`EnumMapper::InqEnum`/`InqString` route through a table id of `0x18`
(§1.3), which is very likely a DAT-resident enum/string table (the
lookup falls through `MasterDBMap::DivineType`-style DBObj resolution
seen in `EnumMapper::GetEnumByDID`,
`acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories),
meaning the actual keyword strings (e.g. whatever text maps to
`m_type == 1`) live in a DAT string/enum table, not as a compiled
string literal — grepping for literal tag keywords like `"IID"`,
`"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category
`0x18`'s EnumMapper table contents (likely in
`client_local_English.dat` or `client_portal.dat`) to find the actual
keyword-to-`m_type` mapping, the same way
`claude-memory/project_settings_options_digest.md`'s `GetNameFromKey`
work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`.
- **Exact wording of the two `sprintf` format strings** at
`acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and
`acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's
string-literal rendering is known to mis-escape embedded
slashes/percents in this codebase; both are flagged inline in §1.3/§2.3
as probable artifacts (a stray `%` in the first, a `\` that's likely a
literal `/` in the second). **Needs**: a raw byte dump of the two
`.rdata` string constants at their addresses (not BN's decompiled
preview) to confirm exact bytes before porting the exact markup
syntax.
- **The exact semantics of `SetFontColorHelper`'s three virtual calls**
(vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from
control flow, not named by the PDB. The functional summary (indexed
colour array) is believed correct, but the precise interface
(`BaseProperty`'s exact virtual table) was not independently
confirmed against `acclient.h`'s `BaseProperty`-family struct
definitions in this pass.
- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function
resolves a click position to a glyph, reads its `m_tag`, and invokes
`HandleClick` through the vtable — was not located in this pass (no
literal-text call site exists to grep for an indirect vtable call).
- **Which `NoticeHandler` override actually consumes
`RecvNotice_TextTag_IIDStringClick` and prefills the chat input** —
not traced. This is the last link needed to fully confirm "clicking a
chat name populates a `/t` tell," though the `IID` + name payload
shape on `TextTag_IIDString` makes it the overwhelmingly likely
candidate tag type for that UI behaviour.

View file

@ -1,267 +0,0 @@
# Retail building and environment detail texturing — #226 port note
**Date:** 2026-08-21 · **Amended:** 2026-08-22 (Campaign VM, VM1)
**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED; re-ported to retail's
single-pass detail combine by VM1 after
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) proved real
hardware never takes the two-pass framebuffer fallback this note originally
described. The "Exact two-pass pseudocode" and "Brightening decision"
sections below are corrected in place; everything about the reachable
setting/caller chain, the authored source data, and material coverage is
unchanged and still applies.
This note is the implementation handoff requested by #226. The measurements
below come from the already-completed
[`2026-08-21 terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md),
especially §§12. They are cited here rather than re-derived. The reachable
preference/caller chain is also recorded in
[`2026-07-10 detail texturing`](2026-07-10-detail-texturing.md).
The A2 terrain-normal verdict and A3 subdivision disposition are recorded in
the companion [`Terrain fidelity Track A report`](2026-08-21-terrain-fidelity-track-a-report.md).
## User-visible target and reachable caller trace
The issue title used to say “landscape,” but the Sept-2013 retail client does
not expose live landscape detail through this option:
1. The Options checkbox writes `RenderPrefs.EnvironmentDetailTextures`.
2. `Render::UpdateFromPreferences` (`0x0054d850`) explicitly changes
`Current_Render_LandscapeDetailTextures` to `0` and calls
`SmartBox::SetDetailTexturing(smartbox, 0, environmentEnabled)` at
`0x0054d9f3`.
3. `SmartBox::SetDetailTexturing` (`0x00451df0`) forwards
`LScape::SetDetailTexturing(lscape, landscape, enabled, enabled, 0)`.
4. `LScape::ChangeRegion` (`0x00506cb0`) independently installs the same
category state: `(0, EnvDetail, EnvDetail, 0)`.
The four positions are landscape (0), building (1), environment/EnvCell (2),
and ordinary object (3). The only reachable named-retail preference caller
forces categories 0 and 3 off. `DrawPartCell` also clears ordinary-object
detail. Therefore #226's scene target is **building shells and interior
EnvCell geometry**, not outdoor terrain, scenery, creatures, or players. This
also explains why acdream's existing checkbox is labelled “Building Detail
Textures.”
## Authored source, size, and sampling
Detail data is reached through
`Region(0x13000000).TerrainInfo.LandSurfaces.TexMerge.TerrainDesc[category]`:
```text
SurfaceTextureId = TerrainDesc[category].TerrainTex.DetailTextureId
tiling = TerrainDesc[category].TerrainTex.DetailTexTiling
RenderSurfaceId = SurfaceTexture(SurfaceTextureId).Textures[0]
rgba = decode(RenderSurface(RenderSurfaceId), level 0)
```
For Dereth, enabled categories 1 and 2 both resolve
`0x05001787 -> 0x06006D58`, a **256 x 256 A8R8G8B8** texture, with tiling
**4**. The complete measured Dereth population is three textures across 33
entries: `0x050012AF -> 0x060037D2` (64 x 64, 29 entries),
`0x05001786 -> 0x06006D57` (256 x 256, two), and the enabled-category texture
above (256 x 256, two). See the findings §2 table.
Retail uses wrap addressing in U and V and linear minification,
magnification, and mip filtering. Detail UV is `baseUv * tiling`. The port
therefore uploads each live category as a one-layer RGBA8 texture array with a
full mip chain and the existing repeat/linear world sampler.
## Exact single-pass pseudocode
Retail has both a single-pass multitexture route and a two-pass framebuffer
fallback for adapters that cannot advertise `D3DTEXOPCAPS_PREMODULATE`.
[VM2's live cdb read](2026-08-22-vm2-retail-detail-path-cdb.md) on the
PDB-paired retail client found `m_caps.bCanDoSinglePassDetailing = 1` and the
file-static `trysinglepass = 1` on real (AMD) hardware, so
`D3DPolyRender::RenderMeshSubset` (`0x0059ca10`) never falls back for built
meshes — every loaded `CGfxObj` sets `use_built_mesh = 1`
(`CGfxObj::InitLoad` `0x005346b0`). The port below matches the path players
actually saw.
```text
enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option
buildingDetail = load_category(TerrainDesc[1])
environmentDetail = load_category(TerrainDesc[2])
for each retail built-mesh material subset:
draw_existing_base_subset_unchanged()
if enabled and subset belongs to a building or EnvCell:
draw the same subset with its category detail texture
// transparent/additive/inverse-alpha: detail follows its base
// immediately, before the next delayed-alpha subset
for each replayed fragment:
reject ordinary objects / landscape / scenery
accept opaque, ClipMap, alpha, additive and inverse-alpha subsets
// No distance term. ACRender::get_alpha_for_z (0x006b6230) is only
// evaluated in D3DPolyRender::DrawPolyInternal (0x0059d7c0, the
// immediate-polygon path) and only when the static noFadeDetail
// (0x00820e38, initialised to 1) is 0 — unreachable for built meshes.
// Attenuation is the LINEAR mip chain converging to the texture mean.
detail = sample(categoryTexture, baseUv * categoryTiling)
diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the
// translucency-fade multiplier
// for a fading subset.
// tmpmaterial.Diffuse.a = 1f
// (0x0059cb99) is the
// burnedInStaticLights < 0 &&
// *(render_device+0x7e4) == 0
// branch in RenderMeshSubset;
// the other branch leaves
// diffuse FromVertex. Either
// way the opaque->1 /
// fading->opacity mapping
// still holds.
// D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup:
// stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
// stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuseAlpha * detail.a
// stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = lerp(current.rgb, detail.rgb, stage0.a)
src.rgb = detail.rgb
src.a = detail.a * diffuseAlpha
depth test = EQUAL opaque; LESS_OR_EQUAL transparent
depth write = preserve base class // ON opaque; OFF transparent
alpha-to-coverage = OFF // detail alpha is blend input
blend op = ADD
source = SRC_ALPHA
destination = ONE_MINUS_SRC_ALPHA
```
The pixel this produces is `lerp(base * diffuse, detail.rgb, detail.a *
diffuseAlpha)` — a blend **toward** the detail colour by
`detail.a * diffuseAlpha`:
```text
result = base * (1 - detail.a * diffuseAlpha) + detail.rgb * (detail.a * diffuseAlpha)
```
`detail.a * diffuseAlpha == 0` is an exact no-op (fully-transparent detail
texel, or a translucency fade that has reached zero). At `detail.a *
diffuseAlpha == 1` the result is exactly the detail colour. There is no
"neutral gray" point — this is a lerp, not the fallback's multiplicative
`1 + fade * (detail.rgb - detail.a)` factor.
### Built-mesh material coverage and order
The land-polygon `SurfaceType & 4` exclusion does **not** narrow this built-mesh
port. Named-retail `RenderDeviceD3D::DrawEnvCell` (`0x0059f170`) and
`DrawBuilding` (`0x0059f2a0`) install `curr_detail_surface` before calling
`D3DPolyRender::DrawMesh`. `DrawMesh` (`0x0059d4a0`) bypasses delayed-alpha
queuing while that surface is installed and passes detail enabled to
`RenderMeshSubset` (`0x0059ca10`) for each material subset. The fallback then
redraws that exact subset with the detail surface before proceeding. Therefore
ClipMap, straight-alpha, additive, and inverse-alpha built-mesh subsets are
included alongside plain opaque ones.
The Vulkan port first filters the opaque object command stream to coalesced
runs containing at least one category-1 building instance; nonbuilding-only
commands never reach the detail pipeline. A mixed instanced command remains in
the replay and `mesh_detail` rejects its ordinary instances individually. The
accepted opaque path stays batched, while transparent subsets preserve
immediate base/detail adjacency. Their separate detail pipeline
keeps depth writes disabled, matching the base subset's accepted depth
contract. This prevents another shell/object contribution from being
composited between the base and its detail contribution.
Opaque detail uses depth compare **EQUAL** against the exact geometry just
written by the base pass. Vulkan depth is per sample, so on MSAA ClipMap edges
the detail affects only samples whose base alpha-to-coverage mask wrote depth.
The detail pipeline itself deliberately keeps alpha-to-coverage off: detail
alpha controls `ONE_MINUS_SRC_ALPHA` in the retail blend and is not the base
coverage mask. Transparent bases do not write depth, so their adjacent detail
uses `LESS_OR_EQUAL` with depth writes still off.
One bounded ordering seam is explicit: retail bypasses its delayed-alpha queue
while `curr_detail_surface` is installed, whereas acdream retains its already-
authoritative shared alpha-queue order and inserts the detail draw immediately
after the corresponding base draw. This does not narrow material coverage or
change base coverage/blend/depth behavior; it avoids making the checkbox
reorder the default transparent scene. The connected acceptance matrix must
still exercise overlapping transparent building/EnvCell surfaces.
Also unmodelled: retail's stage-1 OUTPUT alpha — `MODULATE(TEXTURE, CURRENT)`
(`0x0059c549`) — which for a delayed-alpha subset becomes the framebuffer
blend weight the alpha queue composites that subset with. acdream instead
draws the base subset with its own alpha and a second, separately blended
draw weighted by `detail.a * diffuseAlpha` (the pipeline in
`VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)`). For
opaque subsets this is identical (both reduce to the base's own alpha
gating nothing else downstream); on translucent building/EnvCell subsets it
is a bounded difference in how much the SUBSEQUENT alpha-queue compositing
sees, registered as its own row AP-232 (blend WEIGHT on translucent subsets), distinct from AP-34 (queue ORDER)
row rather than a new one.
## Darkening, not brightening
The earlier version of this note read the two-pass fallback's `DEST_COLOR +
ONE_MINUS_SRC_ALPHA` as retail's blend and reported the findings doc's
**1.177**, **1.204**, and **1.033** framebuffer-multiplier measurements for
the three Dereth textures as intentional retail brightening. VM2 showed that
factor formula belongs to the fallback only, which real hardware does not
run. The single-pass lerp above has the opposite sign: with the live
Dereth building/environment category texture (mean rgb 0.165, mean alpha
0.132) and opaque diffuse (`diffuseAlpha = 1`), the combine is
`≈ 0.868 × base + 0.022` — a mild **darkening** of roughly 10% on mid-tones,
not a brightening. This is retail's actual on-screen behavior on the
hardware the game shipped on; it is not a visual correction and carries no
opt-out. See
[`RetailDetailTextureContract`](../../src/AcDream.App/Rendering/RetailDetailTextureContract.cs)
and the VM2 note for the exact numbers.
## What the reverted experiment got wrong
The experiment described by `c25d6186` was never committed as renderer code;
it was reverted from the worktree with `git checkout`. Its useful failure
record remains in that issue commit. It differed from the verified contract in
five material ways:
- It targeted outdoor landscape, while the live setting enables building and
environment categories and forces landscape off.
- It built a per-terrain-type texture array, while retail selects one
category-scoped surface and scalar tiling for each draw path.
- It used `base * detail * 2` (`MODULATE2X`) instead of retail's framebuffer
blend.
- It assumed 128 gray was neutral; retail neutral is RGB equal to alpha.
- Its acceptance prohibited an overall brightness change, although retail's
measured blend intentionally brightens these textures.
The old OpenGL-specific array/bindless wiring is also not reusable in the
current Vulkan-only RHI.
## Corrected acceptance
- With `BuildingDetailTextures=false`, no detail replay is submitted and the
current base rendering remains unchanged.
- With it `true`, toggling the existing Options checkbox **visibly changes
buildings and interior/EnvCell surfaces** without a restart. The connected
2026-08-21 Facility Hub A/B/A gate applied the real Config checkbox on ->
off -> restored-on and captured the same nearby walls/floor after each
transition. Static right-wall mean absolute RGB error was 2.132 for on/off
versus 0.007 for original-on/restored-on; the floor row was 3.385 versus
0.013. The persisted setting was observed false during B, restored true,
and the session ended with ACE-confirmed graceful logout.
- Outdoor terrain, ordinary scenery/objects, creatures, and players do not
gain this overlay.
- Every built building/EnvCell material subset is eligible: opaque, ClipMap,
straight alpha, additive, and inverse alpha. Transparent base/detail draws
remain adjacent in acdream's authoritative shared alpha order.
- Opaque object replay submits only command runs containing a building; mixed
commands are filtered per instance. Depth equality inherits the base pass's
per-sample ClipMap coverage without applying A2C to detail alpha.
- There is no distance fade (VM1, VM2): `noFadeDetail` gates
`get_alpha_for_z` to the immediate-polygon path only, which built meshes
never reach. Attenuation is the sampler's linear mip chain converging to
the texture mean; a building reads the same well past 50 m as it does at
10 m, not a hard step.
- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
sampling match the measured Dereth data.
- The retail single-pass combine darkens the live category texture by
roughly 10% on mid-tones (VM2); this is expected. There is no
`dst=ZERO` correction mode and no brightening two-pass fallback hidden
behind the retail checkbox.
- Physics, collision, walkability, and geometry are untouched.

View file

@ -1,650 +0,0 @@
# Retail chat WINDOW shell — window model, filters, scrollback, chrome
**Date:** 2026-08-21
**Status:** RESEARCH ONLY. No source files touched.
**Scope:** retail's chat window SHELL and DISPLAY behavior — window
management, filtering, scrollback, chrome/interaction, multi-window,
line-composition structure, and other user-visible window mechanics.
**Explicitly out of scope** (covered by sibling research this session):
glyph text-tag coloring, clickable/colored names, tag click dispatch, and
acdream's own current UI code. This document does not re-derive anything
already answered there.
**Primary sources**
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
- `docs/research/named-retail/symbols.json`
**Notes read first so this extends rather than repeats:**
- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research
— window lifecycle/identity, LayoutDesc geometry, resize model, opacity,
persistence, multi-window/floaty mechanics). **This document is the
authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and
§5 tabs/multi-window — I only summarize its findings below with pointers,
and add what it doesn't cover:** scrollback/truncation, the exact
window-ID routing predicate as a single decompiled function, structural
line-composition order, and the unseen-text/auto-scroll interaction.
- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage,
`m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not
re-derive; I use its findings to cross-check the routing function decoded
fresh below.
- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger.
**Binary-Ninja caveats (apply throughout, per
`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field
attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted
by one slot relative to the true member order — the window-shell doc already
documented this for the main window's border elements. I hit the same
artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence
(§1) and resolve it the same way: against the verbatim struct order in
`acclient.h:54898-54912`, which is authoritative and does not shift.
---
## 1. Window model
### 1.1 How many windows, and how they're identified
Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface`
struct):
```cpp
/* 6041 */
struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field
{
unsigned int m_eWindowID;
float m_fDefaultOpacity;
float m_fActiveOpacity;
float m_fCurrentOpacity;
UIElement_Text *m_chatEntry;
UIElement_Text *m_chatLog;
UIElement *m_chatNewNonVisibleTextIndicator;
unsigned __int64 m_llTextTypeFilter;
UIElement_Text *m_pChatTargetButtonText;
PStringBaseArray<unsigned short> m_InputHistory;
unsigned int m_LastInputHistoryPos;
ClientCommunicationSystem *m_pCCS;
};
```
Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live
chat windows exist — the main window (`m_eWindowID == 8`) and four floating
windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED
constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets
`this->m_eWindowID = 0;` before `PostInit` reads the real value off the
LayoutDesc attribute `0x1000007E`). All five windows are authored,
always-resident children of the gameplay-UI root — there is no
runtime-allocated window registry (window-shell doc §1.1).
The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a
`ChatInterface` subclass, not part of this window-id space (per
`claude-memory/project_chat_digest.md`).
### 1.2 The wire-to-window routing predicate — one function, load-bearing
`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single
function every displayed chat line passes through. Its **head** (the routing
decision, before any text is appended) is:
```
004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo(
class ChatInterface* this, uint32_t arg2 /*type*/,
class StringInfo const* arg3 /*body*/,
class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/)
004f4640 {
004f4640 uint32_t eax_7 = arg5;
004f4652 if (eax_7 == this->m_eWindowID)
004f4652 {
004f467c label_4f467c:
… (appends — see §3/§6) …
004f4652 }
004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0))
004f4666 goto label_4f467c;
004f4640 }
```
**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0`
AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a
"windows subscribe to a channel" model:
- A line sent with a **specific windowId** (matching an already-open target
window, e.g. a command whose output is explicitly directed at the window
that issued it — `m_idCurrentCommandSource` per the color-table doc §4)
is shown **only** in that one window, unconditionally — the destination
window's own filter is never consulted for an address-targeted line.
- A line sent with **windowId == 0** ("broadcast") is shown in **every**
window whose own `TypeIsActive(type)` (i.e. its 64-bit
`m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is
how the same "Sio says, ..." line can land in the main window and in a
floating window simultaneously if both have `Speech` enabled.
- **Window id 0 is therefore never itself a *window* — it is the broadcast
sentinel value on the wire/call parameter, exactly as the window-shell
doc's goal-window addendum states.** No live `ChatInterface` instance ever
keeps `m_eWindowID == 0` after `PostInit` runs.
`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the
color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`.
---
## 2. Filters
**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not
re-derived here.** Summary for completeness of this document's structure:
- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`),
read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)`
(`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via
`RecvNotice_GameplayOptionChanged @0x004F30E0`.
- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) &
m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2)
and, per the color-table doc, nowhere else.
- **`PostInit`'s per-window seeded default** switches on `m_oldState`
(`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main
window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`);
2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets
Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4)
gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's
HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start
disabled in **every** window and must be opted into by the user.
- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` /
`AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData`
block per window id (main = id 8, with its own dedicated Society checkbox
child at `0x0049FEFB`).
- Squelching is a **separate axis** from filtering:
`LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value
subset of `LogTextType` as squelchable at all; it has no interaction with
`m_llTextTypeFilter`.
Nothing new to add here beyond what the color-table doc already covers —
the routing predicate decoded fresh in §1.2 above is a second, independent
confirmation of the same "`windowId==0` → filter-gated broadcast" model that
doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation
alone; this document supplies the full decompiled function body.
---
## 3. Scrollback
### 3.1 The cap, the trigger, and the trim target
Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately
after the body append (full excerpt with the append order in §6):
```
004f4701 int32_t m_chatLog_1 = this->m_chatLog;
004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710)
004f4711 {
004f4713 int32_t var_14_4 = 0x1d4c;
004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1);
004f4711 }
```
`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript
offset `+0x61C` tracks the transcript's total **character** count (not a
line count) — retail's scrollback limit is a character budget, not a
fixed number of retained lines. **Trigger: transcript exceeds 10,000
characters. Target: trim back down toward ~7,500.** This runs on every
appended line once the log is over budget — it is not a periodic/timed
sweep, it is inline in the same call that just displayed the line.
### 3.2 The truncation rule — trims at a newline boundary, not mid-line
`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at
the only call site found):
```
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
004f4290 {
text = GetText(m_chatLog); // live PStringBase
currentLen = text.length; // *(len_ptr - 4)
004f42c2 if (currentLen <= arg2)
return; // under budget — no-op
004f42c2 else
{
004f42c8 excess = currentLen - arg2; // chars over target
… PStringBaseIter_Common<unsigned short>::FindChar(iter, "\n", 1) …
// search FORWARD from the excess offset for the next '\n'
004f4350 if (found && (excess - foundPos) < (currentLen / 10))
004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline
else {
… FindChar(iter, "\n", 0) … // search again, other direction arg
004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10))
goto (the same BeheadText-at-newline path)
004f4403 else
BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset
}
004f4290 }
```
Reading this at the BN pseudo-C level is genuinely uncertain past the
overall shape — **flagging per the assignment's constraint rather than
guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the
standard MSVC constant-division-by-10 idiom (`length / 10`), and the two
`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below,
§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are
unambiguous. **UNKNOWN — needs a live cdb capture with real transcript
content to nail down exactly:** whether the two `FindChar` calls search in
opposite directions from the excess offset (my reading above) or whether
one is a fallback re-search after the first's 10%-tolerance check fails for
a different reason; the two `arg3` values passed to `FindChar` (`1` then
`0`) are almost certainly a direction or "case-sensitive/whole-word" flag,
but the pseudo-C never names the parameter. **What is certain and
sufficient to port:** truncation removes text from the FRONT of the
transcript (`BeheadText`), it PREFERS a boundary within the char that
begins the next `\n`-terminated line rather than a raw char-offset cut
(there's a ~10%-of-current-length tolerance band around the target for
preferring the newline-aligned cut), and it falls back to an exact
char-offset behead only if no acceptable newline is found nearby.
### 3.3 The separator character — confirms `\n`, not `\r\n`
```
0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here
0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle
```
`data_79c288` is passed both as the inter-line separator string appended in
`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in
`TruncateChatLog` above — confirming truncation genuinely searches for line
breaks, i.e. it is line-boundary-aware even though the budget itself is
counted in characters.
### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition`
`UIElement_Text::IsAtVerticalEnd @0x00469350`:
```
00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this)
00469350 {
lineCount = this->m_glyphList.m_glyphList._num_elements;
00469359 if (lineCount == 0)
return 1; // empty log counts as "at end"
00469360 lastLineIndex = lineCount - 1;
00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex);
00469350 }
```
**This is not a scroll-offset comparison — it is "is the last line
currently visible inside the viewport right now."** `IsPositionInView` is
the same hit-test the widget uses for click-to-position, applied to the
transcript's own final line.
`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the
new line, then decides what to do with it **after** appending and
truncating:
```
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands
… append prefix, append body, truncate if over budget (§6, §3.1) …
004f4723 if (ebx != 0)
004f4723 {
004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom
004f4739 return;
004f4723 }
004f4723 else
004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead
```
**So: if the user was already looking at the bottom of the log, retail
scrolls the new line into view (sticky-bottom). If the user had scrolled up
into history, retail does NOT move their scroll position at all — it
instead lights the "new unseen text" indicator.** There is no separate
manual "scroll lock" toggle; this automatic per-line check IS retail's
scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real
`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element
id `0x1000048C` — the 16×16 button the window-shell doc's layout dump
already placed at (21,62) in the main window and (5,169) in the floaties,
labeled there "new-unseen-text indicator (Button)" from the authored rect
alone; this document supplies the code that drives it.
### 3.5 Clearing the unseen-text flag
`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case,
`idElement == 0x1000048c`:
```
004f51f1 if (idElement == 0x1000048c)
004f51f1 {
if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below
004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount);
004f520d indicator->vtable->SetState(0xd);
}
```
**Field-attribution caveat:** this function's local variable is BN-named
`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it
scrolls is described by `_num_elements` of its own `m_glyphList` — the
transcript's own line count, not the chat-entry input field's. Combined
with the ctor/struct order (§1.1) and the same-class shift already
documented in the window-shell doc for `PostInit`, the operation this
really performs is: **clicking the unseen-text indicator scrolls the
transcript to its own bottom and resets the indicator's own visual state**
(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set
when new text arrives while scrolled up) — i.e. clicking it is the user's
manual "catch up" action, and it un-flags itself. **Not independently
re-verified via cdb; treat the exact numeric visual STATE values (1 vs
0xd) as confirmed, but the specific field bound to "which object gets
scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd`
elsewhere, not a literal read of this function's own variable names.**
---
## 4. Window chrome & interaction
**Fully covered by the window-shell doc §1 and §2§4 — not re-derived
here.** Summary pointers:
- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per
window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`
(bottom/left/right/top); the main window's plain top edge strip is a
`UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip —
window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing
@0x0046B7E0`.
- **Docking/anchoring:** none found — windows are free-floating, clamped
to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo
@0x004D2D10:004d2d53-004d2dbb`).
- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property
0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081`
focused), applied to the WHOLE composited window surface including text
via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity
@0x004F3120`. Per-class constructed starting values differ (main window
1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides
them. Retail eases toward the target at 5%-of-delta per tick
(`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently
snaps (AP-190, window-shell doc §3.1).
- **Show/hide:** authored elements toggled via `SetVisible`, driven by
either a keybind (`Alt+1..4` for the floaties) or a generic
registered-action click dispatch — window-shell doc §1.3/§1.4.
- **Persistence:** two independent paths — the per-window `GameplayOptions`
blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the
main window's geometry is NEVER saved this way) and a separate local
screen-layout text file that IS the only path persisting the main
window's geometry — window-shell doc §4.
**One piece of chrome not covered by the window-shell doc — the talk-focus
menu (main window only):**
`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from
the button/group pair at elements `0x10000014`/`0x10000015`, window-shell
doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying
an `Enum` attribute `0x1000000B` set to a distinct small integer (`1`
through `0xD`) that records which "talk focus" (broadcast target category)
that menu row represents:
```
004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90);
… (13x) …
004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5);
004cdd07 SmartArray<UIElement_Text *,1>::push_back(&this->m_aTalkFocusButtons, &var_94);
```
`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows'
enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion
`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently
highlighted target whenever the player's WORLD selection changes (via
`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is
a **world-object selection** feed (F1-click on an NPC), not a transcript
text-tag click, and is out of this document's lane beyond noting that the
main window's talk-focus button exists and is driven from it. Only the
main window has this menu; floaty windows (window-shell doc §2.2) have
neither a talk-focus menu nor a max/min button, only a title bar and close
button.
---
## 5. Tabs / multiple windows
**Fully covered by the window-shell doc §1.1§1.4, §2, §4.1 — not
re-derived here.** Summary:
- There is no "tab" widget. The five windows (§1.1) are five separate,
independently positioned/sized/opaque floating panels, not tabs of one
container.
- **Creation:** none — all five exist from gameplay-UI construction; users
cannot create additional windows. **Naming:** each floaty window has an
editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted
option `0x1000008D`) but the SET of windows is fixed at five; there is no
"new chat tab" affordance analogous to modern MMO UIs. **Closing:**
floaty windows close via their own title-bar close button
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element
`0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at
all (no close button is authored on it — window-shell doc §2.1's element
table has none). **Switching:** there is no focus-cycling shortcut found;
each window is an independent, simultaneously-visible panel, and
"switching" only means moving keyboard focus into a different window's
entry field by clicking it (which is what drives the opacity fade,
§4/window-shell doc §3).
- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2),
`DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell
doc §3 correction), position/size/visible/title (§4), and the transcript
itself (`m_chatLog`, independently truncated per §3 — each window keeps
its own scrollback, so a floaty showing only Tells has its own 10k/7.5k
character budget separate from the main window's).
- The main window's four indicator buttons (`0x10000522`-`0x10000525`)
mirror the four floaties' visibility as one-directional state indicators,
not a tab strip — window-shell doc §1.4.
---
## 6. Timestamps, prefixes, and line composition order
### 6.1 The two-part composition model — confirmed structurally
`ClientSystem::AddTextToScroll @0x00563C50` is where a body string
(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a
windowId (`arg5`) become the two `StringInfo` arguments
`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant
branch (client-local `0x1A` short-circuits both the timestamp AND the local
log file):
```
00563de6 if (arg3 == 0x1a)
00563de6 {
// build body-only StringInfo, EMPTY prefix StringInfo
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId);
00563de6 }
00563de6 else
00563de6 {
00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0)
00563dfb {
00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space
00563e39 PStringBase<unsigned short>::set(&prefixBuffer, &buf);
00563dfb }
… if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4
}
```
**There is exactly ONE structural prefix element: the timestamp, and it is
entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a
character option toggle backed by `PlayerModule::options2_` bit 6 —
`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) &
1`).** There is **no separate structural "channel name" prefix element**
(`[Fellowship]`, `[<name>]`, etc.) anywhere in this function or in
`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear
in retail's transcript (documented already, by content not structure, in
the color-table doc §3.3's channel-bit table) are baked directly into the
`arg2` body string by the SENDING handler (e.g.
`Handle_Communication__ChannelBroadcast`) before it ever reaches
`AddTextToScroll` — from this function's point of view there are only ever
two composed parts: prefix (timestamp-or-empty) and body.
### 6.2 The append order — separator, then prefix, then body
Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the
excerpts in §1.2/§3.1/§3.4 stitched together in call order):
```
004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0)
004f4687 {
004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/);
004f467c } // 1. separator (skipped on the very first line)
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc);
// 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/);
// 3. body — colored by the wire LogTextType
```
**Fixed structural order: `[\n if not first line] → [timestamp, if
enabled] → [body]`.** The leading separator is a property of the LOG
(inserted once per new entry, before the entry, so the transcript never
starts with a blank line), not a property of the entry itself — a port that
appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK
identical on screen but will behave differently under `TruncateChatLog`'s
newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting
(§3.4) if the two approaches disagree at the very first/last line. The
color assignment itself is the color-table doc's territory (not re-derived
here) — the load-bearing NEW fact this document adds is the *order* and
that the timestamp is unconditionally color index `0x0C` regardless of the
body's own type, which the color-table doc §3.2 already states from the
same address; this document supplies the surrounding append sequence and
confirms the timestamp's StringInfo is `arg4`, always appended strictly
BEFORE the body `arg3`, never interleaved or after.
### 6.3 Timestamp format, verbatim
`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute,
second, **no date**, one trailing space baked into the format string
(explaining why no separate space-insertion code exists between prefix and
body — the prefix string itself carries its own trailing separator).
---
## 7. Other user-visible window behaviors
### 7.1 Local session log file — a port would miss this
`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to
during the session, independent of the on-screen transcript's 10k/7.5k
character budget (§3.1) or any window's filter (§2). Written from the same
`AddTextToScroll` branch that builds the on-screen timestamp (§6.1):
```
00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer);
```
Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly
bypasses this — client-local errors/refusals never reach the log file,
only the on-screen transcript. **UNKNOWN — needs further grep:** the log
file's path/naming convention and whether it rotates per-session or
per-character; not chased further as it's a filesystem-artifact question
more than a window-UI one, but flagged because "retail also writes a
plain-text chat log to disk" is exactly the kind of behavior a UI-only port
would miss entirely.
### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5
The `0x1000048C` "new unseen text" indicator button IS retail's unread
marker. It is per-window (each `ChatInterface` owns its own
`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line
arrives while the user has scrolled away from the bottom, and clears when
the user clicks it (which also snaps the transcript back to its bottom).
There is no separate "flash the window" or "flash the taskbar/app icon" —
`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump
(checked via a whole-file grep; zero hits).
### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated
A targeted grep for `PlaySound`/`SoundManager::Play*` near the
`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler
body found no sound-manager call inside it, and no `Sound_*`-named constant
resembling "tell received" or "chat" turned up in the identifiers swept.
The one chat-adjacent audio-related symbol found is a **global** preference
`Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound`
(`UIPreferences::AttachPreference @0x004037E4`,
`SoundManager::PlaySoundInternal @0x0054FEC0` checks
`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`)
— which mutes ALL UI sounds (not specifically chat) when the game window
isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb
capture on an incoming tell**: this document did not find a chat-specific
sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak
evidence of absence given how many code paths route through indirect
vtable calls the text search can't follow. Flagging rather than asserting
"retail has no tell sound."
### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability
`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll
@0x004678D0` exist as capabilities of the general text-widget class that
BOTH the chat entry field (`m_chatEntry`) and the read-only transcript
(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed
`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER
text-entry fields (a character-name box, a stack-size entry box) triggered
by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this,
0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything
chat-specific. **UNKNOWN — not independently confirmed for the read-only
transcript specifically:** whether the transcript panel exposes the SAME
click-drag-select-then-copy affordance as the entry field, or whether it is
flagged read-only in a way that suppresses selection; the class-level
capability clearly exists on the type, but no chat-transcript-specific
selection code path was located distinct from the generic `UIElement_Text`
mouse-down handler already cited. Worth a live-client check (select text in
the retail transcript, see if a selection highlight appears) rather than
further static digging.
### 7.5 What's genuinely absent
- No `FlashWindow` anywhere in the binary (§7.2).
- No docking/snapping between chat windows or to screen edges — the
window-shell doc's resize/move research found only free-floating
clamped-on-restore positioning (§4).
- No tab strip / tabbed-window container (§5) — five independent panels,
not a tab model.
- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the
scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no
user-facing on/off switch found.
---
## Behaviours acdream is most likely missing
Ordered by how load-bearing each gap looks against `RuntimeCommunicationState`
(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and
`ChatWindowController` as of this session:
1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char
target / newline-boundary-preferring trim (§3.1§3.3) appears to exist in
acdream today — grep `TruncateChatLog`-equivalent behavior in
`ChatLog`/`ChatWindowController` before assuming an unbounded transcript
is fine; it will diverge from retail under long play sessions (memory
growth) and, more subtly, under the exact wrap point if a port ever needs
pixel/line parity with a retail screenshot at high message volume.
2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split
(§3.4§3.5).** This is a genuine behavioral fork, not a cosmetic one: a
naive port that ALWAYS scrolls to bottom on new text will yank the user's
scroll position out from under them mid-read whenever a broadcast line
arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to
prevent. Confirm `ChatWindowController` checks "was I at the bottom
before this line landed" before auto-scrolling, and confirm the
`0x1000048C` unseen-indicator element (window-shell doc's layout dump
already has its rect for both window layouts) is wired to light up +
clear via click exactly as §3.4/§3.5 describe.
3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The
color-table doc already flags the routing behavior; this document adds
the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat
windows model (per the CH6c plan in the window-shell doc §6.1) implements
precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)`
not, e.g., "every window with the type enabled shows every line
regardless of address," which would make addressed command-output lines
leak into windows they were never meant for.
4. **Structural composition order (§6.2)** — separator-before-entry (not
after), timestamp-before-body, timestamp always present-or-absent as a
single unit gated on one option bit. A port that concatenates
`timestamp + " " + body` as one string loses retail's separately-colored,
separately-truncatable prefix run and the option-driven all-or-nothing
presence.
5. **The local session chat-log file (§7.1).** Small, but "retail writes a
plain-text transcript to disk every session" is the kind of feature users
notice is missing only when they go looking for it after the fact.
6. **Per-window independent scrollback.** Once §1 is implemented, confirm
each of the five windows truncates its OWN transcript independently
(§5) rather than sharing one global buffer — a floaty window filtered
down to just Tells should never truncate early just because the main
window's transcript is huge.
7. **Sound cues and transcript text-selection are open questions, not
confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has
none of this" code around them; re-check live if/when they become
relevant.

View file

@ -1,349 +0,0 @@
# Terrain detail, terrain normals, and atmospheric rendering — verified findings
**Date:** 2026-08-21 · **Status:** RESEARCH / HANDOFF — no code written
**Tree:** `main` @ `255b0aae` (branch `claude/git-sync-status-5fb1d2`, level with main)
Everything below was **measured** against the installed DATs, the current
source tree, or the named-retail decomp. It exists so the next session does not
re-derive it. Where a claim was tested and **refuted**, that is recorded too —
those are the expensive ones to rediscover.
---
## 1. The detail-texture overlay is NOT implemented (and the setting lies)
**Renderer:** `terrain_modern.frag` / `.vert` contain **zero** detail
references — no second sampler, no detail UV, no distance-fade term. Tree-wide
there is no `DetailTex` / detail-surface concept in
`src/AcDream.App/Rendering/`. Verified at `255b0aae`, not only at the older
`bb1640f7`.
**But the SETTING exists and is DEAD:**
| Piece | Location |
|---|---|
| `bool BuildingDetailTextures = true` | `AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:77` |
| persisted as `"buildingDetailTextures"` | `SettingsStore.cs:99` (read), `:677` (write) |
| Options checkbox, retail string `ID_Graphics_BuildingDetailTextures` | `ConfigOptionsPageController.cs:772-774` |
| **consumers in `Rendering/`** | **none** |
The Options window shows "Building Detail Textures", checked by default,
persisting across sessions — wired to nothing. This is worse than not having
the setting: the UI implies a feature that does not exist. **#226's port must
consume this existing setting, not invent a new one**, and its acceptance test
must include "toggling it visibly changes the scene".
Already shipped and NOT to be confused with this: **#155 base tiling**
(`bb5acab9`, ported `TexMerge::CopyAndTile`/`Merge`) — that fixed the
*stretched* look. #155 and #226 were conflated once already.
---
## 2. How retail's detail pass actually works
### Authored data (measured from the installed DATs)
Detail textures are authored **per terrain entry**:
`TerrainTex.DetailTextureId` + `TerrainTex.DetailTexTiling`, reached via
`LandSurf::GetDetailTex` / `GetDetailTiling`.
Region `0x13000000` "Dereth": **33 terrain entries, only 3 distinct detail
textures.**
| Detail texture | RenderSurface | Size | Used by |
|---|---|---|---|
| `0x050012AF` | `0x060037D2` | **64x64** A8R8G8B8 | **29 of 33 (88%)** |
| `0x05001786` | `0x06006D57` | 256x256 A8R8G8B8 | 2 — BarrenRock, LushGrass |
| `0x05001787` | `0x06006D58` | 256x256 A8R8G8B8 | 2 — Grassland, Ice |
`DetailTexTiling` is mostly **1** (4 for grass/rock types, 8 for
FauxWaterRunning, 2 for RoadType) against a base `TexTiling` of 2.
This confirms the community observation that the client "uses the same noise
texture for everything" — one 64x64 texture backs 88% of Dereth's terrain.
### Four categories, and which the setting actually gates
`LScape::SetDetailTexturing` (0x00506b40) manages **four** independent detail
surfaces + tilings: `0 = landscape`, `1 = building`, `2 = environment`,
`3 = object`. Also `GenerateDetailSurface` (0x00506230),
`CleanupDetailSurfaces` (0x00504ae0).
`LScape::ChangeRegion` (0x00506cb0) calls:
```c
SetDetailTexturing(this, 0, EnvDetail, EnvDetail, 0);
// ^landscape=OFF ^building ^environment ^object=OFF
```
gated on the single `Render::m_RenderPrefs.EnvironmentDetailTextures`. **So
retail's setting is a BUILDINGS-AND-INTERIORS setting, not a terrain
setting** — which explains why its UI label is "Building Detail Textures".
`SmartBox::SetDetailTexturing` (0x00451df0) *can* pass landscape through.
**ANSWERED 2026-08-22 (VM2 cdb read):** `Render::m_RenderPrefs.LandscapeDetailTextures`
is a real, separate preference and reads **0** on the live client
(`EnvironmentDetailTextures = 1`); `landscape_detail_surface` is null while
`building_detail_surface`/`environment_detail_surface` are set. Terrain detail
is off by preference, and there is no Options row for it.
### The blend — the FALLBACK path (see the correction below)
`ACRender::SetDetailSurfaceInternal` (0x006b6280):
```c
SetStageTexture(stage, detailTex);
SetSamplerAddressMode(stage, TEXADDRESS_WRAP, TEXADDRESS_WRAP);
SetSamplerFilterMode(stage, TEXFILTER_LINEAR x3);
if (stage == 0) {
SetBlendFunction(curr_detail_src_blend, curr_detail_dst_blend, BLENDOP_ADD);
SetAlphaBlendEnable(1);
SetDepthBufferMode(DEPTHTEST_LESSEQUAL, 1);
}
```
It is a **framebuffer blend, not a DOT3/normal-map path.** Factors differ per
category:
| Path | site | src | dst |
|---|---|---|---|
| environment / interiors | `0059f1c2` | `9` = `BLEND_DSTCOLOR` | `6` = `BLEND_INVSRCALPHA` |
| landscape | `005a19a1` | `5` = `BLEND_SRCALPHA` | `6` = `BLEND_INVSRCALPHA` |
| static default (`.data`) | `0081eca0` | 5 | 6 |
`DSTCOLOR + INVSRCALPHA` under `BLENDOP_ADD` evaluates to
`dest x (detailLum + 1 - alpha)`. Decoded means of the three real textures:
| texture | lum | alpha | factor | effect |
|---|---|---|---|---|
| `0x060037D2` | 0.459 | 0.282 | **1.177** | **+18% brighter** |
| `0x06006D57` | 0.414 | 0.210 | **1.204** | **+20% brighter** |
| `0x06006D58` | 0.165 | 0.132 | **1.033** | **+3% brighter** |
**The FALLBACK detail pass brightens rather than roughens** — and, **CORRECTED
2026-08-22 (VM2/VM4)**, the fallback is not what real hardware runs. The
framebuffer blend above is taken only when `stage == 0`, i.e. when the adapter
cannot advertise `D3DTEXOPCAPS_PREMODULATE`. A live cdb read on the owner's AMD
GPU gave `m_caps.bCanDoSinglePassDetailing = 1` and `trysinglepass = 1`, so
retail uses the single-pass texture-stage path in `D3DPolyRender::SetSurface`
(0x0059c4d0): `lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)` — a mild
blend toward the detail colour (about 10 % on mid-tones with the live category
texture). The community's "reflect more instead of being rougher" describes
the fallback only. Full evidence:
[`2026-08-22-vm2-retail-detail-path-cdb.md`](2026-08-22-vm2-retail-detail-path-cdb.md).
The #226 port was re-done to the single-pass math at Campaign VM VM1.
### Terrain draw path
`ACRender::landPolyDraw`**two overloads**, `0x006b6320` (single-poly) and
`0x006b6760` (two-poly). Detail is gated on
`trysinglepass && m_caps.bCanDoSinglePassDetailing && curr_detail_surface != 0`,
then `SetDetailSurfaceInternal(1)`. Single-pass multitexture, with a
non-single-pass fallback that is **untraced**. Vertex lighting comes from
`ACRender::curLandBlockVertexLighting`.
---
## 3. REFUTED claims — do not re-chase these
- **"client_highres.dat is an override dat and we pick the low-res copy."**
FALSE. Measured twice, including at `255b0aae`: portal **20,684**
RenderSurfaces, highres **2,294**, **overlap 0** — fully disjoint IDs.
`TextureCache` (`Portal.TryGet || HighRes.TryGet`) and
`DatCollectionAdapter.TryResolvePreferred` (Portal then HighRes) both reach
them, so every high-res surface resolves. **We already use them.** The
"Portal precedence is load-bearing" comment in `DatCollectionAdapter` is
vestigial given zero overlap. QUESTION CLOSED.
- **"The detail textures are normal maps."** FALSE. The 64x64 one is
blue-dominant (RGB 0.303/0.469/0.824), which *looks* like a tangent-space
normal map, but unpacking RGB as a vector and measuring length gives
**24.3% / 7.7% / 14.9%** unit-length pixels (a real normal map is ~100%).
They are colour+alpha detail textures.
- **"AC has no normal-map capability at all."** ALSO FALSE — the engine has a
`BumpMap` parameter, the `DotProduct3` texture op, and a hardware capability
probe `m_caps.bTexOpDotProduct3` (read at `0x0059f5c7`). The machinery
exists; the detail textures simply are not normals. What *uses* the bump path
is untraced.
---
## 4. Terrain geometry and normals
- Landblock = **192 m**; 8x8 cells @ **24 m**; 9x9 = **81 height samples**;
2 triangles per cell => **128 triangles per landblock**.
- Heights are **quantised**: each sample indexes a **256-entry**
`region.LandDefs.LandHeightTable`. This caps what smoothing or subdivision
can achieve — they smooth quantisation, they do not recover unsampled detail.
- Diagonal split direction comes from the `FSplitNESW` hash of world cell
coords (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, `0x519B8F25`).
- `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs` `GetNormal` returns the
**flat per-face normal**. **CORRECTED 2026-08-22 (VM4):** that function only
orients procedural scenery (`SceneryGenerator.cs:166`); it never fed the
render normal, and the claim that it produced a faceted look was wrong. The
rendered mesh already used smooth central-difference normals (Phase 3b,
`LandblockMesh`). Retail (`CLandBlockStruct::calc_lighting` 0x00531700)
averages the unit plane normals of each vertex's incident polygons; Campaign
AR's A2 ported that, replacing central differences — a real parity port with
a subtle visual delta (VM0: 1114 % of pixels at mean |Δ| ≈ 11.5).
**Open question, must be answered from the decomp, no guessing:** does retail
smooth terrain vertex normals? Entry points: `ACRender::landPolyDraw` (both
overloads, note `ACRender::curLandBlockVertexLighting`),
`LScape::calc_object_light` (0x00455730), the LScape sunlight/ambient block
~`0x00455b51`, and how a terrain `CPolygon`'s vertex normals/colours are built.
`TerrainUtils` is WorldBuilder-derived, so WB may have simplified — **check
retail, not WB.**
- retail smooths => **parity gap**, schedule like #226.
- retail is faceted => opt-in enhancement, belongs with the shader-pack work.
Smoothing costs **zero geometry change** and therefore **zero physics risk**:
collision, walkability, slope tests and the 4M-cell conformance sweep all see
identical triangles.
**Subdivision is NOT recommended** and the next session should try to refute
rather than confirm that: perceived softness is texture-frequency (§1-2) plus
flat shading (this section), not silhouette resolution; the 256-step
quantisation caps the gain; and it is the only one of the three that touches
the physics contract — those triangles ARE the collision surface (FloorZ /
ValidateWalkable, walkable-polygon tracking, precipice/cliff slide, the 4M-cell
conformance sweep, and the triangle-boundary Z bug that cost five failed fix
attempts). If ever wanted, the only defensible forms are coplanar
surface-preserving subdivision (identical surface, better Gouraud gradients) or
an interpolating spline through the original 81 samples, render-only, with a
registered bounded divergence.
---
## 5. Renderer state relevant to atmospheric work
- **9 shader pairs** (`debug_line`, `mesh_modern`, `particle`, `particle_mesh`,
`portal_depth`, `sky`, `terrain_modern`, `ui_text`, `vk_probe`) plus a
compiled `spv/` directory.
- `mesh_modern` uses **per-vertex Gouraud** lighting deliberately — the A7
comment records that a per-pixel evaluation produced a hard "spotlight pool"
unlike retail's fixed-function T&L. 8-light `SceneLighting` UBO carrying
`uFogParams` / `uFogColor` / `uCameraAndTime`. Two-pass alpha (opaque discard
`<0.95`, translucent discard `>=0.95` and `<0.05`).
- **Sun direction already exists and already tracks the day/night cycle:**
`WorldRenderFrameBuilder.cs:526`
`-SkyStateProvider.SunDirectionFromKeyframe(keyframe)`, supplied as
`LightKind.Directional` (`:531`, `:546`). The sun is a **real drawn object**
(`dayGroup.SkyObjects` via `SkyPesFrameController`).
- **A depth-only pipeline shape already exists:** `portal_depth.vert/.frag`
with `GpuPipelineDescription.ColorWrite = false`
(`Gpu/GpuPipelineDescription.cs:276`, honoured in
`Gpu/Vk/VulkanGpuPipeline.cs:205`) and an empty fragment `main()`. **A shadow
map is that pipeline aimed at the sun.**
- `activeDayGroup` (weather: Clear / Cloudy / Overcast / Rainy) reaches the
frame builder.
- Campaign V (OpenGL -> Vulkan) closed 2026-07-29: pass-based RHI
(`IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder`, explicit
`GpuPassDescription` + `GpuPipelineDescription`) over bindless + MDI.
### Performance baseline and the binding constraint
**519.7 FPS; CPU/GPU p50 1.869 / 1.096 ms** (CLAUDE.md), and the dense-town
profile is **CPU-SUBMISSION-BOUND** (memory:
`feedback_render_perf_measurement`).
**VM0 baseline (2026-08-22, connected, uncapped Release, no automation
observer, `ACDREAM_FRAME_PROF=1`; `2026-08-22-vm0-default-path-invariance.md`):**
| Spot | Binary | CPU p50 / p95 | GPU p50 | alloc KB/frame |
|---|---|---|---|---|
| Holtburg | `6c79d35c` | 4.7 / 5.1 ms | 0.4 | 574 |
| Holtburg | Campaign AR, pack off | 4.1 / 4.4 ms | 0.4 | 21 |
| Arwic (dense) | `6c79d35c` | 6.0 / 6.5 ms | 0.7 | 582 |
| Arwic (dense) | Campaign AR, pack off | 5.2 / 5.6 ms | 0.7 | 29 |
Any figure captured under `ACDREAM_AUTOMATION_ARTIFACT_DIR` carries the
render-scene observer's allocation and is NOT comparable with this table.
Consequences:
- GPU-side **fullscreen** work is nearly free in observed FPS — it fills GPU
idle time. Tier-1 post-processing costs little.
- Anything that adds **CPU submissions** is expensive. **Shadow cascades must
not re-run visibility culling per cascade on the CPU** — draw the full
resident set per cascade, or move culling to a GPU compute pass.
- `glFinish`-style profilers **inflate** GPU timings. Measure with the existing
gates (capped/uncapped, p50/p99 CPU+GPU, pinned dense Arwic).
---
## 6. Wanted work — atmospheric rendering (user-stated)
Opt-in **shader packs**, modelled on Minecraft's Iris/OptiFine: the
retail-faithful path stays the **default and authoritative**; enhanced
rendering is a toggle, exposed through the **plugin API**. This framing is what
resolves the parity tension — the divergence register is untouched because the
faithful path still exists and is still what we test.
| Tier | Contents | Prerequisite |
|---|---|---|
| 1 | bloom, ACES filmic tonemap, colour grade, vignette | none — fullscreen, scales with pixels |
| 1 | screen-space sun rays (crepuscular) | sun screen-pos + occlusion mask; **no shadow maps** |
| 2 | **cascaded directional shadows** — trees, monsters, houses | second scene pass |
| 2+ | volumetric light shafts | reuses the shadow map — nearly free after shadows |
| later | SSAO, water reflections | depth + normals |
| out | true PBR | AC has no per-texture normal/roughness maps |
**Dynamic shadows are an explicit user want** — trees, monsters and buildings
casting real sun shadows — not merely a design exercise.
Design points the user asked for:
- Ray/shaft intensity driven by **AC's own authored weather**
(`activeDayGroup`) rather than invented constants — strong shafts at clear
dawn, muted under Overcast. Combined with the already-authored moving sun
this yields rays that rake low at dawn, vanish at noon, return at dusk.
Retail never had this.
- Composite sun rays **before** tonemapping so bloom picks them up and they
roll off the filmic curve instead of clipping.
### Shadow-specific constraints
1. **Alpha-tested casters.** Foliage is cutout geometry (two-pass alpha), so
the shadow pass **cannot** use `portal_depth`'s empty fragment shader — it
must sample and discard, or every tree casts a solid rectangle.
2. **Animated casters.** Monsters need the same per-part transforms as the main
pass; those already live in the N.5 SSBO, so the shadow vertex shader can
read the same buffer.
3. **Indoors has no sun.** Gate to outdoor cells; dungeon cells use authored
per-cell ambient and must not be fought.
4. **Cascades must be camera-relative**, bounded by the two-tier streaming
window (memory: `reference_two_tier_streaming`), not a fixed world extent.
5. **Depth bias will hit issue #129's bug class** — an NDC-space bias constant
spans `~ b*d^2/near` **metres** of eye depth at distance; #129 leaked
door-shaped holes through hills. Memory:
`feedback_ndc_constants_eye_space_meaning`.
6. **NAMING TRAP:** in AC's codebase "shadow" means the per-cell **physics**
registration list (`CPhysicsObj::add_shadows_to_cells`, `shadow_objects`) —
nothing to do with lighting. Grepping "shadow" drowns in collision hits.
7. Quality scaling for weak hardware: half/quarter-res bloom and rays, fewer
cascades, lower shadow resolution. 4K pays ~4x the tier-1 pixel cost.
---
## 7. Open questions carried forward
1. Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs
enhancement.
2. Who calls `SmartBox::SetDetailTexturing`, and is landscape detail ever
enabled in practice? (§2)
3. What is the non-single-pass detail fallback in `landPolyDraw`? (§2)
4. What uses the `BumpMap` / `DotProduct3` path, if not the detail textures?
(§3)
5. ~~For #226: port retail's brightening blend verbatim, fix it, or expose
both?~~ **ANSWERED 2026-08-22:** neither — the brightening blend was the
fallback; VM1 ported the single-pass lerp that real hardware runs (§2).
## 8. Where this sits
M4 is the active milestone; the next planned implementation work is the
**#268 + TS-8 stat-chain package**, and none of the above should displace it
without the user's say-so. #226 and the terrain-normals question are **parity
gaps** (schedulable now). The shader-pack tiers are **post-M7**, since
rendering phases are frozen until the polish pass — but they are wanted, so the
design should be captured rather than rediscovered.

View file

@ -1,81 +0,0 @@
# Terrain fidelity Track A report
**Date:** 2026-08-21
**Status:** REPORT ACCEPTED BY OWNER DIRECTION; A1/A2 IMPLEMENTED; A3 REJECTED
This report answers Track A from the measured
[`terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md).
It cites that evidence rather than repeating its measurements, and it does not
reopen the findings' three refuted claims. The project owner subsequently
authorized implementation. No physics or collision behavior changed.
## A1 — #226 detail-texture overlay
The complete source/size/tiling, blend, neutral point, distance units, setting
gate, material-ordering contract, reverted-experiment analysis, and connected
A/B/A evidence are in the
[`#226 retail building/EnvCell detail-texturing port note`](2026-08-21-retail-building-detail-texturing-pseudocode.md).
The report conclusions are:
- The reachable user-visible target is **building shells and interior EnvCell
geometry**, not outdoor terrain. The Options preference caller and
`LScape::ChangeRegion` both install category state `(landscape=0,
building=enabled, environment=enabled, ordinary=0)` through
`SmartBox::SetDetailTexturing`.
- The existing **Building Detail Textures** checkbox is the sole setting gate.
No second option was added. Toggling it now visibly changes the connected
Facility Hub scene without a restart.
- The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` blend verbatim,
including the measured slight brightening. `dst=ZERO` would be an opt-in
visual correction, not parity; exposing both meanings behind the one retail
checkbox would make that preference ambiguous.
- The reverted experiment targeted landscape, built the wrong texture-array
shape, used `base * detail * 2`, assumed 128 gray was neutral, and rejected
the brightness change that the measured retail blend actually produces.
## A2 — terrain vertex normals
**Verdict: parity gap. Retail smooths shared terrain vertices.**
The decisive named-retail function is
`CLandBlockStruct::calc_lighting` at `0x00531700` in
[`acclient_2013_pseudo_c.txt`](named-retail/acclient_2013_pseudo_c.txt):
1. It zeroes one three-float accumulator for every shared landblock vertex.
2. From `0x00531774` through `0x005317F6`, it walks every terrain polygon and
adds that polygon's plane normal (`CPolygon + 0x20..0x28`) to the
accumulator of each of its three vertex IDs.
3. From `0x00531817` through `0x00531886`, it normalizes every accumulated
vector, falling back to `(0, 0, 1)` only for a degenerate sum.
4. The following sunlight/ambient loop dots those normalized shared-vertex
normals with `LScape::sunlight` and writes per-vertex lighting.
That is incident-face normal averaging. **VM4 correction (2026-08-22):** the
findings doc's premise that `TerrainUtils.GetNormal` produced a faceted render
was wrong — that function only orients procedural scenery
(`SceneryGenerator.cs:166`) and never fed the render normal. The rendered
terrain mesh already had smooth central-difference normals (Phase 3b,
`LandblockMesh`). The real change A2 made is central-difference smoothing →
retail's split-aware incident-face average: a genuine, decomp-verified parity
port, but a subtler visual change than either document implied (VM0 measured
1114 % of pixels at mean |Δ| ≈ 11.5 on the same view).
The approved port is in `LandblockMesh.BuildRetailVertexNormals`. It uses the
same split hash and exact emitted triangle topology, accumulates each
normalized incident face normal at the shared 9 x 9 height-sample vertex, and
normalizes the sum. Tests independently reconstruct the average from emitted
positions/indices and prove every position and index is unchanged.
This is lighting-only parity: the 81 height samples, 128 triangles, split
directions, terrain surface, collision triangles, walkability, and physics
owners are byte-for-byte/topology-equivalent to the prior path.
## A3 — subdivision
**Agree: the standing “not worth doing” recommendation survives.** The
findings §4 already establishes that the 9 x 9 samples are height-table
quantized, so subdivision cannot recover missing terrain detail; changing the
surface would create physics divergence, while coplanar subdivision would only
interpolate a surface whose retail-correct shared-vertex smoothing is now
already present. No subdivision work is scheduled.

View file

@ -1,75 +0,0 @@
# Campaign AR completion audit
**Date:** 2026-08-22
**Branch:** `codex/atmospheric-rendering-campaign`
**Status:** all implementation and machine-local gates complete; not shipped
## Audit boundary
This is the requirement-by-requirement closeout audit for
[Campaign AR](../plans/2026-08-21-atmospheric-rendering.md). It uses the
measured evidence and constraints in the
[terrain and atmospheric findings](2026-08-21-terrain-and-atmospheric-rendering-findings.md)
without re-deriving them or revisiting that report's three refuted claims. The
detailed connected results remain in the
[Stage 2 report](2026-08-22-atmospheric-stage2-connected-gate.md).
The last remote fetch reported `origin/main` at `6c79d35c`; merge `99cf26e0`
is in this branch and `git rev-list --left-right --count HEAD...origin/main`
reported zero missing main commits. Campaign commits remain local and have not
been pushed.
## Requirement ledger
| Requirement | Verdict | Authoritative evidence |
|---|---|---|
| Stage 1 project-owner stop | Pass | The owner's 2026-08-22 “Looks good!” acceptance, after exposure 1.0 → 0.80, is recorded in the [live-gate report](2026-08-22-atmospheric-stage1-live-gate.md). |
| Retail-faithful path stays default and authoritative | Pass (evidence strengthened by VM0, 2026-08-22: exact pack-off pixel identity vs `6c79d35c` on static content and a clean production perf A/B — see `2026-08-22-vm0-default-path-invariance.md`; the no-op oracle named here is a 2x2 recording-device fixture) | The checked-in production no-op oracle, six physical pack-off rows, connected disable/restore transitions, and package-failure rows all pass. A branch diff contains only newly named atmospheric/detail shader sources plus the generated shader manifest; no pre-campaign retail shader source is modified. |
| Physics and collision unchanged | Pass | `git diff origin/main...HEAD -- src/AcDream.Runtime src/AcDream.App/Physics src/AcDream.Core/Physics` is empty. The connected routes use the existing gameplay simulation without changing it. |
| Tier 1 bloom, filmic tone mapping, colour grade, vignette, neutral settings, resize, and retained-UI/private-view isolation | Pass for implementation and available automation/physical rows | Slice 1 tests and the RX 9070 XT 30-row matrix pass; final project-owner image judgment remains below. |
| Tier 1 authored-sun rays | Pass for implementation and connected state transitions | Authored sun/day-group/weather, occlusion-mask, behind-camera/off gates, and pre-tonemap composition are automated; connected time/weather transitions pass. Final physical-display dawn/noon/dusk, occlusion, and edge-flicker judgment remains below. |
| Tier 2 moving authored sun/moon shadows are the headline | Pass for implementation, Stage 1 owner gate, and available connected rows | The selected-source contract, stabilized cascades, opaque/cutout casters, animated transforms, receivers, outdoor/indoor gating, and weather energy pass. Connected metadata proves terrain, outdoor statics, buildings, animated statics, local player, non-player creatures, other dynamics, and equipped children. The distinct-account remote-player row remains below. |
| Headline caster membership without fabricated identity | Pass | Diagnostics report source-owned `OutdoorStatics` and `NonPlayerCreatures` rather than guessing tree or hostile-monster identity. The transition capture published 6,624 casters and the capped route reached 9,533. |
| Low/Medium/High/Auto scaling and weak-hardware fail-safe | Pass on available hardware | The exact RX 9070 XT 30-row matrix passes. The integrated AMD Auto run retires atomically to retail with a visible reason and a 0.00365% paired sky-masked difference. Other physical adapters remain below. |
| Tier 2+ sun-only volumetric shafts | Pass for implementation and available performance/lifecycle rows | Sun-only selection, weather/indoor gates, independent quality, depth reuse, neutral A/B, and the 2,048-sample reference-GPU cost row pass. Final owner occluder/weather judgment remains below. |
| Connected select/disable/re-enable, resize, weather, interior/dungeon, portal, reconnect, and fresh renderer/device recreation | Pass | [`logs/connected-world-gate-20260822-132443/report.json`](../../logs/connected-world-gate-20260822-132443/report.json): three clean graphical processes, graceful exits, 77/77 terminal render-scene shadow parity, zero mismatch, and zero pending deltas. |
| Dense turning/frame-pacing and desktop-performance investigation | Pass | Matched Medium/retail dense-Arwic reports show the reported turning hitch on both paths. Medium does not worsen CPU p95 (32.2 vs 32.9 ms) and adds about 0.70.9 ms GPU work. Pack-owned CPU/GPU/memory measurements stay within Medium budgets. |
| Long-run resource convergence | Pass | [`logs/connected-r6-soak-20260822-134004.report.json`](../../logs/connected-r6-soak-20260822-134004.report.json) completes the 516.064-second nine-stop route with movement/jump/combat, revisits, convergence, and graceful shutdown. Deterministic 12-cycle and fresh-device fixtures also pass. |
| External pack install/select/update/remove/fail/recover | Pass | [`artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json`](../../artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json): all six fresh connected graphical processes pass; failures create no half-active pack and corrected recovery is explicit. |
| Public pack API and SDK | Pass | BCL-only v1 contracts, manifest schema, semantic bindings, compatibility guide, validator, built-in pack, no-op sample, and two Tier-2 samples are present. External projects reference only `AcDream.Plugin.Abstractions`, not App or Vulkan. |
| Shaders and generated artifacts | Pass | Managed regeneration compiles 24/24 Vulkan pairs. The final release gate reports no tracked SPIR-V difference after regeneration. |
| Locked restore, Release build, and complete hermetic tests | Pass | [`artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/release-gate-summary.json`](../../artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/release-gate-summary.json): clean exact code commit `fa3e7978`, locked restore pass, zero-warning/error Release build, and 15,179/15,179 tests passing with zero skips/failures across 14 assemblies. |
| Campaign documentation and divergence registration | Pass | Campaign plan, SDK docs, Stage 1/2 reports, roadmap pointer, release-gate graph update, IA-24, and this audit are present. Phase identifier remains **Campaign AR**. |
| M4 priority is not displaced | Pass | The roadmap and campaign plan retain M4 as the active gameplay milestone and describe Campaign AR as owner-authorized parallel work. No roadmap milestone is reassigned. |
## Exact remaining external gates
No further renderer, SDK, tool, test, or machine-local documentation work is
known from this audit. Campaign AR remains deliberately **not shipped** until
all three evidence classes below pass:
1. **Distinct-account remote player.** Run
`tools/run-connected-render-pack-remote-player-gate.ps1` with the primary
`ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` and distinct observer
`ACDREAM_TEST_OBSERVER_USER`/`ACDREAM_TEST_OBSERVER_PASS` environment
variables. Require a nonzero authoritative `RemotePlayers` caster count plus
a visible moving remote-player shadow.
The same-account attempt is recorded in
[`artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json`](../../artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json): the observer entered and moved, then ACE rejected the concurrent primary session. The corrected gate does not serialize credentials.
2. **Unavailable physical adapters.** On every other supported physical Vulkan
adapter, run the source-identical matrix for pack-off, Low, Medium, High,
and Auto at 1920×1080, 2560×1440, and 3840×2160, each capped and uncapped.
That is 30 rows per adapter. The installed RX 9070 XT already passes all 30;
the installed integrated AMD row proves Auto-to-retail fallback only, so
active Low and the other 29 rows on that adapter also remain external.
3. **Final project-owner visual acceptance.** Compare pack off and pack on for
Tier-1 neutral output and private views/UI; sun rays at dawn/noon/dusk,
behind-camera and occluded states; foliage cutouts; moving local, remote,
creature, tree, and building shadows under sun and moon; indoor gating;
source transitions; temporal shimmer/pixelation and cascade/bias artifacts;
sun-only volumetric weather/occluder behavior; and exact pack-off
restoration. The owner must explicitly accept both sides before the plan or
roadmap says shipped.
These are evidence gates, not open implementation slices. Any failure reopens
only the behavior it contradicts; passing them closes the campaign.

View file

@ -1,95 +0,0 @@
# Campaign AR Stage 1 automated gate
**Date:** 2026-08-22
**Verdict:** PASS — every Stage 1 gate that does not require physical visual or
desktop-performance judgment is complete. Those judgments were deliberately
outside this automated report and subsequently passed in the
[Stage 1 live-gate report](2026-08-22-atmospheric-stage1-live-gate.md).
## Scope
This report covers the current authored-celestial implementation: the visible
above-horizon sun, dominant haloed moon, secondary moon, and no-source states;
the selected source's direction-versus-energy handoff; the 336-byte render-pack
shadow ABI; the unchanged authoritative retail path; and local deterministic
performance and lifetime contracts.
It does not itself claim that a physical display proves shadow alignment,
source-transition continuity, temporal pixelation/shimmer quality, or desktop
frame pacing. It did not itself begin Campaign AR Stage 2; the subsequent
project-owner live approval did.
## Results
| Gate | Result |
|---|---|
| Shader compilation | 24/24 Vulkan shader pairs ready |
| Retail shader preservation | all 18 pre-campaign SPIR-V SHA-256 oracles exact; no tracked retail SPIR-V change |
| Focused App renderer validation | 344/344 passed |
| Core sky loader | 14/14 passed |
| SDK and standalone pack validator | 30/30 passed |
| MossTank plugin regression | 48/48 passed |
| Forced locked restore | passed for the complete solution graph |
| Complete Release build after locked restore | passed, 0 warnings, 0 errors |
| Fresh-process hermetic Release gate | 14,928/14,928 passed, 0 skipped, 0 failed, 14 assemblies |
| App assembly inside the complete gate | 5,823/5,823 passed |
The release evidence bundle is
[`artifacts/atmospheric-rendering/stage1-moon-release-gate/`](../../artifacts/atmospheric-rendering/stage1-moon-release-gate/).
Its `release-gate-summary.json`, TRX files, logs, environment inventory, and
`SHA256SUMS.txt` are the machine-readable authority for the fresh-process total.
## Performance and lifetime coverage
The complete App gate includes these deterministic contracts:
- `DirectionalShadowCasterFrameTests.WarmStableFrame_AllocatesZero` builds a
9,500-static-caster scene, warms it, then performs 256 stable frames with
zero managed bytes, no additional scene-index copy, no classification, and
no topology rebuild.
- `DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords`
proves dense animated-transform refresh stays allocation-free and does not
reread scene records.
- `AtmosphericCpuStageProfilerTests.WarmedObservationAllocatesNothing` and
`AtmosphericGpuTimerSamplingTests.WarmSamplingDecisionsAllocateZero` keep the
measurement path allocation-free after warmup.
- `RenderPackLongCycleConvergenceTests.RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly`
repeatedly crosses Low, Medium, High, retail selection, resize, injected
failure/recovery, both frame-flight slots, render-generation replacement,
and terminal disposal for 12 cycles. Every pack resource, registration,
receiver candidate, transform owner, texture slot, and pipeline-format lease
returns to its exact baseline.
- `RenderPackLongCycleConvergenceTests.DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice`
proves recreation is complete old-renderer/context/device teardown followed
by an independent activation generation on a fresh device.
These are CPU-side and recording-RHI gates. The historical physical AMD rows
remain valid for their exact pre-moon binaries and stated scope, but they are
not reused as current sun-and-moon image-quality or desktop-performance proof.
## Authoritative-path and scope audit
- Shader regeneration expands includes and injects pack-only definitions only
for pack shaders. Unchanged retail sources retain their existing committed
binaries; the source manifest still forces a recompile after a real source
edit.
- The exact pre-campaign retail SPIR-V oracle passes after ordinary shader
regeneration.
- No source file under `src/AcDream.Runtime` changed for this campaign gate.
- No physics or collision source changed.
- No retail GLSL source changed.
- Pack-off production integration continues to require zero enhancement passes,
resources, casters, cascades, draws, or dispatches and its pinned framebuffer
and resource ledger remain exact.
## Pending project-owner gate
When the desktop is healthy, launch the corrected Release client against ACE
and stop for the project owner to judge:
- sun, dominant-moon, and secondary-moon shadow alignment;
- sun-to-moon, moon-to-moon, and no-source transitions;
- temporal pixelation/shimmer during camera and celestial motion; and
- desktop smoothness, frame pacing, and FPS behavior.
Stage 2 and campaign closeout remain gated on that explicit approval.

View file

@ -1,46 +0,0 @@
# Campaign AR Stage 1 live gate
**Date:** 2026-08-22
**Verdict:** PASS — project-owner accepted; Stage 2 authorized
## Scope
This is the physical-display and desktop-performance stop that followed the
[Stage 1 automated gate](2026-08-22-atmospheric-stage1-automated-gate.md). It
records the project owner's live acceptance of the opt-in Atmospheric pack; it
is not final Campaign AR acceptance.
The owner exercised the Vulkan client against the local ACE server through the
Stage 1 correction rounds: visible authored sun and moon shadows, selection and
configuration persistence, temporal texture/shadow shimmer, frame pacing and
desktop responsiveness, world selection, fullscreen, and final exposure. After
the exposure correction the owner reported **“Looks good!”** and directed the
campaign to synchronize with main and proceed autonomously through Stage 2.
The opt-in Atmospheric exposure changed from `1.00` to `0.80`. The retail
renderer remains the default and authoritative path. Physics, collision,
gameplay, and network behavior are unchanged.
## Matched exposure evidence
The final comparison pinned time, day group, sky, weather, MSAA, route, and
camera framing. Its five screenshots and machine-readable metadata are under
[`artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/`](../../artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/).
| Scene/preset | Retail mean luminance | Atmospheric mean luminance | Delta | Atmospheric p95 delta | Saturation delta | Clipped pixels |
|---|---:|---:|---:|---:|---:|---:|
| Outdoor / High | 0.1090 | 0.1003 | -8.0% | +6.8% | +29.8% | 0% |
| Interior / High | 0.2711 | 0.2813 | +3.8% | -3.5% | +5.2% | 0% |
| Outdoor / Low | 0.1090 | 0.1011 | -7.2% | +8.4% | +30.1% | 0% |
Before the correction, Atmospheric High measured +19.6% outdoors and +24.5%
indoors. The `0.80` correction removes that overexposure without clipping.
The final outdoor Atmospheric High capture reports 6,613 shadow casters, four
cascades, 95,260,912 resident GPU bytes, and 285 performance samples.
## Acceptance boundary
This gate does not fabricate evidence for a second connected remote player,
portal/reconnect or device-recreation lifecycle, external package flows,
long-run convergence, or unavailable physical GPU classes. Those remain Stage
2/closeout rows, followed by the final project-owner acceptance gate.

View file

@ -1,192 +0,0 @@
# Campaign AR Stage 2 connected and closeout gate
**Date:** 2026-08-22
**Verdict:** PASS for every machine-local Stage 2 row that the available
credentials and hardware can exercise; a distinct-account remote-player row,
external hardware, and final project-owner acceptance remain explicit gates
## Scope and evidence boundary
This report records the executable ACE-connected, dense-scene A/B, long-life,
external-package, shader, build, and complete-test work that followed the
[Stage 1 live approval](2026-08-22-atmospheric-stage1-live-gate.md). It applies
the measured constraints and budgets already established in the
[terrain and atmospheric findings](2026-08-21-terrain-and-atmospheric-rendering-findings.md);
it does not re-derive them or revisit that report's refuted claims.
The measured binaries were clean-source Release products. The retail renderer
remained the default and authoritative path. No physics or collision behavior
was changed.
## Connected scenario matrix
The three-process connected lifecycle gate passed on exact commit `0b6c6557`.
Its report is
[`logs/connected-world-gate-20260822-132443/report.json`](../../logs/connected-world-gate-20260822-132443/report.json).
The capped six-stop process ran for 249.114 seconds, the fresh uncapped reconnect
for 67.917 seconds, and the pack-transition process for 51.277 seconds. All
three exited gracefully with no hard failures. Full graphical-process teardown
and the subsequent fresh Vulkan context/device construction passed.
The route proved:
- capped and uncapped operation, portal travel, reconnect, fresh process, and
destination publication/retirement;
- outdoor, Facility Hub interior/dungeon, and outdoor-after-dungeon states;
- atomic High select, retail disable, High re-enable, 1024 x 768 resize, clear,
overcast, and rain transitions;
- resource-free retail restoration and active enhanced metadata at every named
screenshot; and
- terminal render-scene shadow parity with zero mismatches and zero pending
deltas.
The connected metadata makes the caster boundary concrete. The transition row
published 6,624 casters: 374 terrain commands, 6,571 outdoor statics, 26
buildings, 10 animated statics, one local player, 14 non-player creatures, one
other live dynamic, and one equipped child. The capped login row reached 9,533
casters, including 922 animated statics; Holtburg after the dungeon reached
9,539. Facility Hub correctly published `Outdoor=false`, zero directional
strength, and zero casters. Outdoor DAT publication still cannot distinguish a
tree from other outdoor scenery, and create-object metadata still cannot
distinguish a hostile monster from another non-player creature, so the report
keeps the authoritative `OutdoorStatics` and `NonPlayerCreatures` labels.
One diagnostic distinction is retained rather than hidden: streaming frames
recorded transient render-frame candidate-order differences before the final
candidate sets and digests converged. The authoritative render-scene shadow
comparison finished 77/77 with zero mismatch in the transition process; no
pack or retail-output divergence remained at the terminal checkpoints.
## Dense turning A/B and frame pacing
Matched dense-Arwic runs used the same route, commit `1f75ce05`, resolution,
camera turn, server, character, entity population (15,522), and warmed scene:
- Atmospheric Medium:
[`logs/connected-dense-town-20260822-133441.report.json`](../../logs/connected-dense-town-20260822-133441.report.json)
- retail/off:
[`logs/connected-dense-town-20260822-133727.report.json`](../../logs/connected-dense-town-20260822-133727.report.json)
| Phase | Retail CPU p50 / p95 / p99 | Medium CPU p50 / p95 / p99 | Retail GPU p50 / p95 | Medium GPU p50 / p95 |
|---|---:|---:|---:|---:|
| Six-second turn | 27.8 / 32.9 / 36.7 ms | 28.6 / 32.2 / 40.2 ms | 0.6 / 1.2 ms | 1.5 / 1.9 ms |
| Stationary | 28.6 / 30.2 / 31.1 ms | 29.1 / 30.9 / 32.6 ms | 0.9 / 0.9 ms | 1.7 / 1.8 ms |
**VM4 correction (2026-08-22):** both runs below executed under the connected
automation observer (`ACDREAM_AUTOMATION_ARTIFACT_DIR` render-scene oracle),
which the Slice H closeout documents as allocating 3.910.5 MiB/frame
(`alloc_kb p50 = 9,830` here, 31 Gen0 collections in 216 frames). The absolute
CPU figures are therefore observer cost, not product cost, and this A/B says
nothing about the owner's turning hitch on the product. Clean production
numbers (no observer, uncapped Release) are in the
[VM0 report](2026-08-22-vm0-default-path-invariance.md): dense Arwic CPU p50
6.0 ms pre-campaign vs 5.2 ms pack-off. The paragraph below is kept as the
historical record of the Medium-minus-retail increment, which the observer does
not distort.
With that caveat: Medium did not worsen turn CPU p95 under the observer (32.2
versus 32.9 ms). It adds the expected GPU work, about
0.70.9 ms at p95/p50 in this run. The Atmospheric screenshot's pack-owned
window reports incremental CPU p50/p95/p99 of 0.209/0.248/0.302 ms, inclusive
GPU p50/p95/p99 of 1.374/1.818/1.869 ms, 14,890 casters, three cascades,
61,655,840 resident bytes, and 44,236,800 transient bytes. Medium therefore
stays inside its declared CPU, GPU, and 128 MiB resident budgets.
Process working/private memory was also recorded, as required: the stationary
Medium process was 1,669.9/2,084.4 MiB versus retail's 1,354.8/1,676.8 MiB.
Those process totals include driver and shared host allocations and are not
substituted for the exact pack-owned GPU ledger. Retirement and terminal
convergence are covered by the long route and deterministic lifecycle gates.
## Long lifetime and package lifecycle
The nine-stop Medium route passed on exact commit `1f75ce05`:
[`logs/connected-r6-soak-20260822-134004.report.json`](../../logs/connected-r6-soak-20260822-134004.report.json).
It ran for 516.064 seconds through Caul, Sawato, Rynthid, Aerlinthe, Sawato
revisit, Holtburg, Caul return, Sawato plateau, and Caul plateau. Every
materialization, movement/jump/combat exercise, return/convergence check, and
graceful close passed. The warnings are expected world/DAT population noise;
there are no renderer, pack-budget, or lifecycle failures.
The connected graphical external-package gate passed all six fresh processes
on exact commit `f92479ac`. Its machine-readable report is
[`artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json`](../../artifacts/atmospheric-rendering/connected-package-lifecycle-20260822-135333/report.json).
| Scenario | Plugin outcome | Renderer outcome | Persisted selection |
|---|---|---|---|
| Install/select v1 Low | loaded | external v1 active | external v1 |
| Replace by v2 with stale v1 selection | loaded | safe retail fallback with exact version diagnostic | retail |
| Explicitly select v2 High | loaded | external v2 active | external v2 |
| Remove package | failed-not-found | safe retail fallback | retail |
| Inject failure after registration | failed and transaction rolled back | safe retail fallback | retail |
| Correct package and reselect | loaded | external v2 active | external v2 |
Every process connected, entered world, captured its render-pack metadata,
disconnected, and exited with code zero. Failure rows created no half-active
pack and recovery required an explicit corrected selection.
## Remote-player gate availability
The connected remote-player gate was exercised on exact commit `a861510a`:
[`artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json`](../../artifacts/atmospheric-rendering/connected-remote-player-20260822-140355/report.json).
The observer entered the world and moved at the target location. The concurrent
primary client then timed out waiting for `CharacterList`: ACE did not permit a
second simultaneous character from the same account. Both clients were closed
cleanly, and the failed row is not represented as renderer evidence.
The gate now reads separate observer credentials from
`ACDREAM_TEST_OBSERVER_USER`/`ACDREAM_TEST_OBSERVER_PASS`, rejects same-account
configuration before launch, keeps credentials out of its report, and fails
fast on session failure. Character indexes are independently selectable because
index zero on two accounts still names distinct characters; the entered-world
identity assertion is authoritative. The nonzero `RemotePlayers` caster row and
visible moving remote-player shadow therefore require only access to a second
account; no missing renderer or test-harness implementation remains.
## Final automated gate
On clean exact code commit `fa3e7978`:
- managed shader regeneration compiled **24/24** Vulkan pairs and produced no
tracked SPIR-V difference;
- forced locked restore passed;
- the complete Release solution built with **0 warnings and 0 errors**; and
- the repository-owned fresh-process gate passed **15,179/15,179** tests with
zero skips or failures across 14 assemblies. App contributed **5,951/5,951**.
The complete logs, TRX files, environment inventory, hashes, and aggregate
summary are under
[`artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/`](../../artifacts/atmospheric-rendering/stage2-closeout-release-gate-fa3e7978/).
An earlier rerun exposed that the dense-shadow allocation assertion had only a
single warmup frame and could include tiered-JIT work under full-suite parallel
load. Commit `d78ce100` changes that test fixture only: it performs a complete
64-frame dense warm window before measurement. The focused test passed 12
fresh-process repeats, the complete App assembly passed, and the final gate
above is the authority; production renderer behavior was not changed by this
hardening.
## Remaining external gates
Campaign AR is not declared shipped by this report. Three evidence classes are
not available to this machine-local run:
1. A second connected client using a distinct account must produce a nonzero
authoritative `RemotePlayers` caster row and a visible moving remote-player
shadow. The connected gate is implemented; the available single account was
rejected by ACE for concurrent play as recorded above.
2. Supported physical GPU classes other than the installed Radeon RX 9070 XT
and integrated AMD adapter must repeat the required pack-off/Low/Medium/
High/Auto resolution and pacing rows. The integrated adapter currently
proves safe Auto-to-retail fallback, not active Low or the complete matrix.
3. The project owner must later perform and explicitly accept the final
pack-off/pack-on visual matrix, including moving sun and moon shadows,
foliage cutouts, temporal shimmer/cascade seams, indoor gating, rays and
sun-only volumetrics, and retail restoration.
These are evidence gates, not authorization for more renderer features. The
current code, SDK, connected lifecycle, package lifecycle, long lifetime, and
machine-local automation are complete.
The campaign-wide requirement ledger and exact external-row definition are in
the [Campaign AR completion audit](2026-08-22-atmospheric-campaign-completion-audit.md).

View file

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

View file

@ -1,200 +0,0 @@
# Dereth celestial shadow sources
**Date:** 2026-08-22
**Status:** measured retail-DAT and named-retail finding; implementation input
for Campaign AR
**Scope:** identify the Dereth sun/moons and define the opt-in pack's dominant
directional-shadow source. This note does not change the retail rendering path.
## Conclusion
Dereth's Region `0x13000000` consistently authors three moving celestial
meshes across all 20 day groups:
1. `0x01001348` is the sun disk.
2. `0x01001F6A` is the large, haloed moon and is the dominant lunar source.
3. `0x01001F67` is the smaller secondary moon.
Retail does **not** provide a separate lighting colour or intensity for each
mesh. `SkyDesc::GetLighting` produces one interpolated directional vector,
colour, and brightness from `SkyTimeOfDay.DirHeading`, `DirPitch`, `DirColor`,
and `DirBright`. The opt-in atmospheric pack therefore uses the selected
visible celestial mesh only for shadow **direction**. Colour and energy remain
the single AC-authored directional-light values.
The deterministic priority is:
1. visible sun whose transformed centre is above the horizon;
2. visible large/haloed moon whose transformed centre is above the horizon;
3. visible secondary moon whose transformed centre is above the horizon;
4. no directional shadow source.
This is a pack enhancement, not a claim that retail cast real-time moon
shadows.
## Evidence and provenance
The investigation followed the project rendering inventory and used the
already-loaded retail structures rather than inventing another sky model.
Evidence came from:
- `artifacts/atmospheric-rendering/sky-heading-dump/client.log`, especially
lines 40-88 for Sunny day group 0 and the corresponding repeated entries for
all later day groups. The dump records the three IDs, visibility windows,
angular sweeps, keyframe directional lighting, and the sun surface.
- A read-only `DatCollection.Get<GfxObj>`/`Get<Surface>` probe against the
installed Asheron's Call DATs, using the same inspection path implemented by
`tools/SkyObjectInspect/Program.cs`, for all three `GfxObj` sort centres,
polygon geometry, surfaces, and texture chains.
- `tools/RainMeshProbe/Program.cs` lines 37-49, which names and audits the
celestial surface set independently of the shadow implementation.
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
`SkyDesc::GetLighting` at `0x00500a80` (around line 261291),
`SkyDesc::GetSky` at `0x00501ec0` (around line 262761),
`GameSky::CalcFrame` at `0x00506f80` (around line 268650), and
`GameSky::UseTime` at `0x005075b0` (around line 269090).
- `docs/research/2026-04-23-sky-retail-verbatim.md`, especially its recorded
directional-light interpolation and `GameSky::UseTime` material updates.
No fresh decompilation was required. The named-retail corpus already answered
the only question the current code and DAT dump could not answer on their own:
whether a moon mesh contributes a second retail world light. It does not.
## Installed-DAT characterization
The following values were read from the installed Dereth Region and the three
referenced `GfxObj`/surface/texture chains. The same three object IDs, windows,
and sweeps occur in every one of the 20 day groups; only their object index
changes between seven-object and weather-heavy groups.
| Role | GfxObj | Day window | Angular sweep | Authored `SortCenter` |
|---|---:|---:|---:|---:|
| Sun disk | `0x01001348` | `0.1600..0.9400` | `-23 deg..203 deg` | `(1050, 0, 0)` |
| Secondary moon | `0x01001F67` | `0.0400..0.2100` | `-20 deg..190 deg` | `(1909.46, 1874.78, -0.0000157485)` |
| Dominant moon + halo | `0x01001F6A` | `0.0000..0.2300` | `-20 deg..190 deg` | `(2066.82, 552.99, 0)` |
The asset chain establishes the visual identities and the dominant-moon
choice:
| GfxObj | Surface | Surface flags | SurfaceTexture | RenderSurface | Image |
|---:|---:|---|---:|---:|---|
| `0x01001348` | `0x080000D1` | Base1Image, Alpha, Additive | `0x050014CD` | `0x0600388D` | 128x128 `PFID_R8G8B8` sun disk |
| `0x01001F67` | `0x080000D2` | Base1ClipMap | `0x05001A6C` | `0x06003894` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D6` | Base1ClipMap | `0x05001A6D` | `0x06003898` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
| `0x01001F6A` | `0x080000D7` | Base1Image, Alpha, Additive | `0x05001A6E` | `0x06003899` | 128x128 `PFID_R8G8B8` halo |
Every listed surface has authored `Luminosity=1`, `Diffuse=1`, and
`Translucency=0`. The large moon's primary quad has roughly 2.3 times the
polygon area of the secondary moon before its still larger additive halo is
counted. That makes `0x01001F6A` the unambiguous dominant lunar visual when
both moons are above the horizon.
These installed-DAT facts are characterization evidence, not an ordinary test
dependency. Unit tests use hand-built `DayGroupData` so clean CI and machines
without retail DATs remain deterministic.
## Direction and visibility contract
`SkyObjectData.IsVisible(dayFraction)` owns the normal, always-visible, and
midnight-wrapping window cases. `CurrentAngle(dayFraction)` owns the authored
arc interpolation, including progress through a wrapping window.
The selected direction must match the sky renderer exactly:
```text
heading = active SkyObjectReplace.Rotate
arc = SkyObjectData.CurrentAngle(dayFraction)
model = RotationZ(-heading) * RotationY(-arc)
anchor = effective GfxObj.SortCenter
direction = normalize(TransformNormal(anchor, model))
```
“Effective” means that an active non-zero replacement `GfxObjId` also supplies
its own `SortCenter`. A replacement with `Transparent >= 1` makes the object
ineligible. The replacement lookup follows the renderer's discrete active
keyframe rule; it does not interpolate replacement fields. A zero, non-finite,
or below/on-horizon transformed direction is ineligible.
This deliberately does not substitute `SkyTimeOfDay.DirHeading/DirPitch` for
moon direction. Those values are the one retail world-light direction. The
moon meshes have separate authored arcs, and the enhancement is specifically
intended to align moon shadows with the moon the player can see.
## Authored light contribution
Named retail `SkyDesc::GetLighting` interpolates the two surrounding
`SkyTimeOfDay` records and produces:
```text
sunVector = DirBright * (
cos(DirPitch) * sin(DirHeading),
cos(DirPitch) * cos(DirHeading),
sin(DirPitch))
directionalColor = DirColor * length(sunVector)
```
`length(sunVector)` is `DirBright`. acdream exposes the resulting colour as
`SkyKeyframe.SunColor`. The pack's scalar authored energy is therefore
`clamp(max(SunColor.r, SunColor.g, SunColor.b), 0, 1)`.
By contrast, named retail `GameSky::UseTime` sends a celestial replacement's
`Luminosity`, `MaxBright`, and `Transparent` to the mesh material through
`SetLuminosity`, `SetDiffusion`, and `SetTranslucency`. It does not install a
second directional light. Texture brightness and moon surface luminosity must
not manufacture extra world-light energy.
Weather/day-group reductions, softness, and elevation ramps remain explicit
render-pack policy. They are not mislabelled as measured retail intensities.
## Parity and safety registration
### Retail behavior
- One interpolated directional world-light channel comes from
`SkyTimeOfDay.Dir*`.
- Celestial meshes follow their own visibility windows and transformed arcs.
- Replacement luminosity/diffusion/transparency changes mesh material state,
not the number of world-directional lights.
- Retail does not render the Campaign AR cascaded real-time object shadows.
### Opt-in pack enhancement
- The pack chooses the visible sun or dominant visible moon direction for its
directional shadow map.
- Moon direction follows the rendered moon; energy remains the single
AC-authored directional channel.
- Sun wins any overlap when its transformed centre is above the horizon;
otherwise the haloed moon wins before the secondary moon.
- This deviation belongs in the atmospheric render-pack entry of
`docs/architecture/retail-divergence-register.md`.
### Unchanged boundaries
- The retail rendering path remains the default and authoritative output.
- Pack-off frames do not resolve or render celestial shadow work.
- Existing retail scene lighting remains driven by `SkyStateProvider`; this
policy does not replace it.
- Physics, collision, containment, selection, movement, and DAT geometry are
untouched. The selected source is an immutable one-frame rendering fact.
## Deterministic acceptance coverage
`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`
locks:
- the three verified IDs and priority independent of object-list order;
- sun overlap, dominant-moon fallback, and secondary-moon fallback;
- fully transparent and effective replacement behavior;
- replacement rotation and the exact renderer transform direction;
- no-visible/no-above-horizon suppression;
- midnight-wrapping visibility and angle progress; and
- directional colour-times-brightness energy, including preservation when no
celestial source is available.
The test fixture is entirely hand-built. It neither requires nor silently
substitutes installed retail DAT content.
The complete non-physical verification result, including shader ABI, exact
retail-binary preservation, performance/lifetime fixtures, locked restore,
Release build, and fresh-process totals, is recorded in the
[Campaign AR Stage 1 automated gate report](2026-08-22-atmospheric-stage1-automated-gate.md).

View file

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

View file

@ -1,136 +0,0 @@
# VM2 — which detail-texturing path does retail actually run? (cdb, live)
**Date:** 2026-08-22 · **Campaign:** VM slice VM2 · **Status:** CLOSED — answered
**Method:** read-only cdb attach to the live PDB-paired retail client
(`C:\Turbine\Asheron's Call\acclient.exe`, v11.4186, PDB GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`, verified `MATCH` by
`tools/pdb-extract/check_exe_pdb.py`), in-world on the owner's AMD GPU.
Scripts: `tools/cdb/vm2-detail-caps.cdb` (+ runner) and
`tools/cdb/vm2-trysinglepass.cdb`. No breakpoints; `qd` at top level.
## The question
Campaign AR's #226 port (`mesh_detail.frag`, pipeline blend
`DSTCOLOR + INVSRCALPHA`) reproduces the path retail takes when
`ACRender::SetDetailSurfaceInternal(0)` is called — the **two-pass
framebuffer fallback**. `RenderMeshSubset` (0x0059ca10) and `landPolyDraw`
(0x006b6320) only take that path when
`trysinglepass == 0 || !m_caps.bCanDoSinglePassDetailing`. Otherwise they
call `SetDetailSurfaceInternal(1)` and the combine is the texture-stage
setup in `D3DPolyRender::SetSurface` (0x0059c4d0). The review (F3) said the
fallback was "probably" what players saw. That was a guess. This settles it.
## The readings (verbatim from the logs)
```
acclient!RenderDevice::render_device->m_caps
+0x008 MaxSimultaneousTextures : 8
+0x00c MaxTextureBlendStages : 8
+0x01c bCanDoSinglePassDetailing : 1 <-- THE ANSWER
+0x01d bTexOpDotProduct3 : 1
+0x01e bTexOpBumpEnvMap : 1
trysinglepass (file-static, decomp 0x00820e40 / 0x00835c04 / 0x00835c10)
00820e40 00000001
00835c04 00000001
00835c10 00000001
acclient!Render::m_RenderPrefs
+0x004 LandscapeDetailTextures : 0
+0x005 EnvironmentDetailTextures : 1
+0x006 MultiPassAlpha : 1
acclient!Render::landscape_detail_surface = 0x00000000 (OFF)
acclient!Render::building_detail_surface = 0x0332e6a8 (ON)
acclient!Render::environment_detail_surface = 0x03416de0 (ON)
acclient!Render::object_detail_surface = 0x00000000 (OFF)
acclient!Render::landscape_detail_tiling = 4
acclient!Render::building_detail_tiling = 4
acclient!Render::environment_detail_tiling = 4
acclient!Render::object_detail_tiling = 4
acclient!Render::curr_detail_src_blend = 5 (static default; unused on this path)
acclient!Render::curr_detail_dst_blend = 6
```
## What this means
**Retail on this hardware uses the single-pass texture-stage path.** The
framebuffer blend that Campaign AR ported — and that the 2026-08-21 findings
doc and the AR review both analysed as "retail's detail pass brightens" —
is the fallback for adapters that cannot advertise
`D3DTEXOPCAPS_PREMODULATE`. A modern AMD driver advertises it. The reviewer's
recollection that consumer drivers rarely did was **wrong**; recorded here so
nobody repeats it.
The single-pass combine, from `D3DPolyRender::SetSurface` with the detail
flag set (args are `(stage, op, arg1, arg2)`; `0 = DIFFUSE`, `1 = CURRENT`,
`2 = TEXTURE`):
```
stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb
stage 0 alpha = PREMODULATE(DIFFUSE, DIFFUSE) = diffuse.a * detail.a (premodulate = multiply by the NEXT stage's texture)
stage 1 colour = BLENDCURRENTALPHA(TEXTURE, CURRENT) = detail.rgb * f + current.rgb * (1 - f), f = stage-0 alpha
stage 1 alpha = MODULATE(TEXTURE, CURRENT) = detail.a * current.a
stage 2 DISABLE
```
So the pixel retail actually draws is
```
lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)
```
a **blend toward the detail colour by the detail alpha** — not
`dest × (detail + 1 α)`. With the live category texture `0x06006D58`
(mean rgb 0.165, mean α 0.132) and opaque diffuse (α = 1), the average
effect is `≈ 0.868 × base + 0.022`: a mild **darkening** noise of roughly
10 % on mid-tones. Rougher and darker — the intended look — not brighter.
Consequences:
1. The community remark "they did it backwards, looks like it reflects
more" describes the **fallback** path. On hardware like the owner's it
does not apply.
2. Campaign AR's #226 port reproduces the wrong path for every modern GPU.
It must be re-ported (below). The existing `RetailDetailTextureContract`
"brightening is expected" statement and the findings doc §2 table are
true only of the fallback and must say so (VM4).
3. There is **no distance fade on either path** (VM1 stands): the stage-1
sampler is LINEAR/LINEAR/LINEAR with WRAP; attenuation is the mip chain
converging to the texture mean.
4. `LandscapeDetailTextures` is a real, separate preference, **0** here.
Landscape detail is off by preference, not only by the `ChangeRegion`
literal; there is no Options row for it in the 2013 client. Terrain
detail remains out of #226's scope.
5. `bTexOpDotProduct3 = 1`, `bTexOpBumpEnvMap = 1`: the DOT3 machinery is
available to retail on this hardware, which makes "what uses the BumpMap
path" (findings §7 q4) a live question, still untraced.
## The re-port (folds into VM1; it is small)
Because the lerp is expressible as a framebuffer blend, the existing
separate detail replay pass stays; only its pixel contract changes:
- pipeline blend: `SRCALPHA + INVSRCALPHA`, `ADD` (the pair retail stores
as 5/6) instead of `DSTCOLOR + INVSRCALPHA`;
- `mesh_detail.frag` outputs `vec4(detail.rgb, detail.a * diffuseAlpha)`,
where `diffuseAlpha` is the base subset's vertex/material alpha (1 for
opaque subsets; the translucent subsets already carry it);
- neutral point: `detail.a == 0` (no blend), not `detail.rgb == detail.a`;
- `RetailDetailTextureContract.FramebufferFactor` becomes
`lerp(1, detail.rgb / base, detail.a)` semantics — rewrite the helper and
its tests around `Expected(base, detail, diffuseAlpha)`;
- the 10 m / 50 m fade is deleted as planned (VM1).
A register row is **not** needed for the single-pass port — it is the
retail path. A row IS needed if the fallback is ever exposed (it should
not be).
## Closed / corrected by this note
- Review F3: answered — single-pass. The review's "probably fallback" was
wrong.
- Findings doc `2026-08-21-terrain-and-atmospheric-rendering-findings.md` §2
"retail's own detail pass BRIGHTENS": true for the fallback only; the
hardware path darkens. VM4 amends the doc.
- AR plan + #226 pseudocode note: same amendment.

View file

@ -1,119 +0,0 @@
# Sky default-script (aurora/lightning/thunder) — the retail mechanism, proven
**Date:** 2026-08-23 · **Issues:** #28 (aurora), #2 (lightning, partial), #29 (clouds — not addressed here) · **Phase:** C.1.5c
## The April contradiction, resolved
Two prior research passes (`2026-04-23-sky-pes-wiring.md`, `2026-04-28-pes-pseudocode.md`)
correctly proved `GameSky` never **reads** `CelestialPosition.pes_id`
(`SkyDesc::GetSky @0x00501EC0` writes it at 0x00501FC9; no reader exists), and
issue #2 therefore banned per-SkyObject PES playback "without new decompile
evidence". This document is that evidence. Both were right and both missed the
actual route:
**The sky PES ids ride the sky Setups' own `DefaultScript`, and retail plays
them through the ordinary object default-script machinery — the `pes_id`
column is a dead mirror of the same ids.**
Verified in the installed Dereth DAT: Setup `0x02000714` (aurora carrier,
parts `0x010001EC`) has `DefaultScript = 0x330007DB` — byte-equal to its
SkyObject's `PesObjectId`. Same holds for `0x02000589→0x3300042C` (thunder
ping-pong), `0x02000588→0x33000428` (thunder variant), `0x02000BA6→0x33000453`
(lightning flash).
## The retail chain (named decomp, every link read this session)
1. `GameSky::UseTime @0x005075B0` (30 Hz) → `CRegionDesc::GetSky`
`SkyDesc::GetSky @0x00501EC0` rebuilds the `CelestialPosition` list.
Per-object visibility: **if `begin_time == end_time` the object is always
included**; otherwise `begin <= t <= end` gates the gfx id (out-of-window →
`INVALID_DID`). The aurora object has `begin=end=0.00` in **all 20 day
groups** → always present, all day.
2. `GameSky::CreateDeletePhysicsObjects @0x005073C0`: an existing sky object
is **kept** when its current DataID equals the wanted gfx id AND the
properties word is unchanged AND (`props & 4``LScape::weather_enabled`
did not flip). Only a mismatch destroys/recreates. ⇒ the aurora object —
same id in every group — **persists across day-group changes**, and its
emitters keep their particle population.
3. `GameSky::MakeObject @0x00506EE0`: `props & 4` objects are only created
when `LScape::weather_enabled != 0`; `props & 1` selects `after_sky_cell`
(post-scene) vs `before_sky_cell`. Creation is
`CPhysicsObj::makeObject(gfx_id, 0, 0)`.
4. `CPhysicsObj::makeObject @0x00513970``InitPartArrayObject`: a Setup
with `default_script_id != 0` sets `state |= 0x80000` and registers via
`CPhysics::AddStaticAnimatingObject @0x00509AF0`.
5. `CPhysicsObj::animate_static_object @0x00513DF0` (per tick): state 0x80000
`ScriptManager::UpdateScripts` (starts/advances the default script — the
PES) and updates the object's `ParticleManager`. This is the ~150/min
`CallPES` churn the 2026-04-30 live trace counted (the thunder PES
`0x3300042C` ping-pongs via `CallPES` chains).
## The particle laws confirmed against our port
- `ParticleEmitterInfo::GetRandomOffset @0x005174A0`: random vector, **minus
its projection onto `offset_dir`**, normalized, × rand[min,max] — a disk
PERPENDICULAR to the dir (a shell when dir is zero). Our
`ParticleSystem.RandomOffset` is byte-faithful. The aurora's 450700 m
"Z-dir" offsets are therefore a horizontal RING around the sky-object
origin, not a column above it.
- `Particle::Update @0x0051C290` writes **only the part origin** per type
formula (our `ComputePosition` matches, incl. Swarm cos/sin); only the
GR/LR parabolic variants rotate.
- `ParticleEmitter::SetInfo @0x0051CE90`: parts are ordinary
`CPhysicsPart::makePhysicsPart(hw_gfxobj_id)`; there is no special "2D
particle" draw. `CPhysicsPart::Draw @0x0050D7A0` always feeds
`DrawMesh(gfxobj[deg_level], &draw_pos)`.
- **The facing law**`CPhysicsPart::calc_draw_frame @0x0050DFA0`, driven by
the FIRST degrade entry's mode (`GfxObjDegradeInfo::get_degrade
@0x0051E4B0`; `viewer_heading` = normalized part→viewer from
`UpdateViewerDistance @0x0050E030`):
```
draw = pos
switch deg_mode:
2: Frame::set_vector_heading(draw, viewer_heading) // face viewer, roll-free
3/4/5: Frame::rotate_around_axis_to_vector(draw, X/Y/Z, vh) // cylindrical, one free axis
else: authored orientation (mode 1, mode 0, out of range)
```
Our renderer camera-plane-aligned every "billboard" particle
(`cameraRight`/`cameraUp`); retail faces each part toward the viewer
per-part and honors constrained modes. `CPhysicsPart::Always2D @0x0050D8A0`
(mode != 1) is only consulted for **cell membership**
(`CLandCell::add_all_outside_cells @0x00533360`), not drawing.
- The aurora emitters (`0x32000455/56/57`): BirthratePerSec 10, max 3,
initial 3, lifespans 3300/900/400 s, StartTrans 0.8 → FinalTrans 1.0
(≤20 % opacity fading to nothing), StartScale 78, sprites
`0x01001A61..63` = single ±137.5 m quads whose 64×64 additive textures are
soft glow blobs (row/col profiled — no banding; peak RGB 3671/255). First
degrade mode = **2** on all three. The visible aurora is therefore nine
huge, faint, viewer-facing glows in a slow Swarm drift — a pulse that
re-brightens when a cohort is reborn (~6.7/15/55 min cycles), not a steady
fixture.
## Why the 2026-08-23 experiment looked like "whole-sky tint"
The debug controller (`ACDREAM_ENABLE_SKY_PES=1`) ran under day group 16
(Rainy) at t=0.125 — inside the lightning window (0.030.19) — so the
**lightning-flash and thunder PES** played at the camera anchor alongside the
aurora. A flash sprite at the anchor IS a full-screen additive wash. The
aurora itself was drowned under a faithful-but-wrong-conditions storm.
## Port deltas (this change)
1. Sky default-script playback becomes **production** (no env flag): every
visible sky object whose Setup carries a `DefaultScript` plays it through
`PhysicsScriptRunner`, anchored at the camera (retail sky-cell space is
viewer-centered), pass-routed by `props & 1`.
2. **Persistence contract**: script/emitter state is keyed by
(gfx id, properties) per `CreateDeletePhysicsObjects` — a day-group flip
that keeps the same carrier Setup must NOT restart its emitters.
3. Weather gating: `props & 4` objects follow the weather-enabled state
(acdream's weather system), matching `MakeObject`'s guard.
4. `calc_draw_frame` facing law in the particle renderer: per-sprite degrade
mode picks face-viewer (2), axis-constrained (3/4/5), or authored (else)
orientation — replacing the blanket camera-plane alignment.
Script ids resolve from the **Setup's DefaultScript** (the retail source);
the `PesObjectId` column is only a cross-check.

View file

@ -1,145 +0,0 @@
# VM6 — does the wind move the trees, and only the trees?
**Date:** 2026-08-23 · **Campaign:** VM slice VM6 · **Status:** see the
verdict at the end; the owner's visual gate (plan §VM6 Acceptance) is still
owed regardless.
**Question.** With the built-in `acdream.atmospheric` pack, does the
foliage-wind displacement reach the **production** world geometry, and is it
confined to procedural scenery foliage?
This note records the apparatus that finally answered it, including the
first method that did not — two wrong conclusions were drawn from it before
the confound was found, and both are recorded here so nobody re-derives them.
## Apparatus
Offline pixel gate (`tools/run-offline-pixel-gate.ps1`, Release, `-SkipBuild`,
`-Uncapped`, 1280×720, day group 0 "Sunny" → `WeatherKind.Clear`, day
fraction 0.5, MSAA 0), isolated settings, the fixed offline scene the VM0/VM3
captures use, drawn through `RetailPViewPassExecutor.DrawPackedProductionRoute`
(the production entity route). `-RenderPackPreset high`; `ACDREAM_SKY_PHASE_SECONDS`
pins the pack's per-graph clock so two captures at the same pin see the same
wind phase. Pixel metric: max-channel |Δ| ≥ 8
(`tools/vm6/wind-pixel-proof.py`, numpy).
### Method 1 (confounded — do not reuse): "t0→t3 with wind on, minus t0→t3 with wind off"
The idea was that the clock pin at 0 s vs 3 s moves only the wind, so the
wind-off pair is a control for everything else the clock drives. It is not:
the same binary, same settings, same pin differs run-to-run by **0 or ~280
px**, bimodally, and those pixels are isolated single-pixel flips along the
treeline's leaf edges (`artifacts/vm6amp/churn-crop.png`) — rasterisation
edge flips, not content. Whether the churn lands in the wind pair or the
control pair is luck, so "wind-only" counts of 23 / 314 / 568 / 703 px from
this method on `43e3abed` / `a82959f1` were all within that churn. The
overlay images looked convincing precisely because the churn sits on tree
silhouettes. Two conclusions drawn from it — "the F1 packed-classifier fix
is confirmed by pixels" and "wind reaches the geometry" — were unsupported.
(The F1 fix itself is correct; the round-3 Opus review traced it to the GPU
word by reading, and the round-4 apparatus below confirms geometry motion.)
### Method 2: same pin, wind on vs off, signal amplified, repeats as the floor
Settings `wind-strength = 2`, `wind-lean-metres = 1`, `wind-branch-metres = 1`,
`wind-flutter-metres = 0.5` make the canopy displacement ~1 m — several
pixels even at this scene's treeline distance, where the default Clear wind
(≤ 11 cm lean) is sub-pixel. Two captures per arm; the repeat pairs measure
the floor; the four cross pairs measure the effect.
## Results
### `fccba839` (round 3), `sun-shadow-strength = 0`
| Pair | px ≥ 8 |
|---|---|
| wind-off A vs wind-off B (floor) | 22 |
| amplified-wind A vs amplified-wind B (floor + churn) | 317 |
| amplified A vs wind-off A / B | 65 / 49 |
| amplified B vs wind-off A / B | 284 / 273 |
| robust mask (all four cross pairs, neither repeat pair) | **1** |
A 1 m wind produced nothing. A CPU probe in `ResolveFoliageWind` (removed
after the run) showed the state was right — first advance at serial 2 snaps
to Clear `(0.25, 0.15)`, strength 1, gate on, one graph instance — so the
uniform was correct and the world pass never used it.
**Cause (by reading, round-4 finding):** the wind was welded to "directional
shadows rendered this frame". `DirectionalSunShadowRenderer.Render` left the
frame binding `Disabled` whenever the environment gate said no;
`WbDrawDispatcher.PipelinesFor` then fell back to the plain `mesh_modern`
pipeline, which has no `foliage_wind.glsl` at all; and
`BindDirectionalShadowReceiver` returned early on `!Enabled`. The gate is
`dayGroupPolicy × sunElevationResponse × strength`, so the wind also stopped
every night, at user strength 0 and under the portal cover. Fixed in round 4
by publishing a valid-but-disabled binding (zeroed shadow block, flags 0 —
the receiver shader already returns visibility 1.0 on that bit — plus the
wind frame slice) so the built-in pack's world pass always runs the receiver
pipeline.
### `eec95535` (round 4), `sun-shadow-strength = 0`
| Pair | px ≥ 8 |
|---|---|
| wind-off A vs wind-off B (floor) | 19 |
| amplified-wind A vs amplified-wind B (repeat) | 51 |
| amplified A/B vs wind-off A/B (four cross pairs) | 13,876 / 13,887 / 13,893 / 13,903 |
| **robust mask (all four cross pairs, neither repeat pair)** | **13,854** |
| **default Clear strength vs both wind-off, floor-excluded** | **1,402** (2,655 at |Δ| ≥ 3) |
Per-60-row band of the robust mask: `[8580, 4101, 800, 61, 0, 110, 14, 188, 0, 0, 0, 0]`
the treeline (rows 0180), the hillside trees (rows 300360) and the
shoreline bushes (rows 420480). Overlays:
`evidence/vm6/eec95535-amplified-wind-vs-off-shadows-off.png` (every tree
and bush red; no house, fence, road, lifestone, ground, water or UI pixel),
`evidence/vm6/eec95535-default-clear-wind-vs-off-shadows-off.png`, the
amplified frame itself `evidence/vm6/eec95535-amplified-wind-frame.png`, and
the Method-1 churn crop `evidence/vm6/method1-run-to-run-churn-crop.png`.
### `754d59d9` (round 5 — gated-off frames light from the authored sun again)
The round-4 narrow review found that round 4's decoupling lit every
shadow-gated-off frame from straight overhead: both receiver vertex shaders
take the sun *direction* from the shadow block, and the disabled block's
`(0,0,1)` guard became the sun. Round 5 makes the receiver verts fall back
to the plain pipeline's `uLights` expression when the flag bit is clear.
Same apparatus, wind-off arm compared across binaries (shadows off, same
pin):
| Pair | px ≥ 8 | mean |Δ| |
|---|---|---|
| `754d59d9` wind-off A vs B (floor) | 65 | 0.002 |
| **`754d59d9` vs `fccba839` (plain pipeline, pre-round-4)** | **277** | **0.007** |
| `eec95535` vs `fccba839` (the round-4 regression) | 32,538 | 1.77 |
Wind on `754d59d9`: robust mask **14,993 px** (amplified), same bands as
round 4.
## Verdict
On `754d59d9` the foliage wind reaches the production world geometry with
the shadow term removed (so it is geometry, not shadow), is 700× the
run-to-run floor when amplified and 70× at the default Clear strength, and
is confined to procedural scenery foliage in this scene. Earlier binaries:
`43e3abed` had no geometry wind at all on the production route (round-2
review F1, confirmed by reading); `a82959f1`/`fccba839` had it only while the
shadow gate was open (round-4 defect); `eec95535` had the wind but lit
shadow-gated-off frames from overhead (round-5 defect).
Commands: `tools/run-offline-pixel-gate.ps1 -Out <dir> -SkipBuild
-RenderPackPreset high -SkyPhaseSeconds 3 -Uncapped
-RenderPackSettingOverrides @{ 'wind-enabled'='true'|'false';
'sun-shadow-strength'='0'; [amplified: 'wind-strength'='2';
'wind-lean-metres'='1'; 'wind-branch-metres'='1'; 'wind-flutter-metres'='0.5'] }`
(Release, two captures per arm as above); analysis
`py tools/vm6/wind-pixel-proof.py <root>` (robust mask = AND of the four
cross-pair masks minus both repeat-pair masks; writes `diff-robust-wind.png`).
## What this does and does not prove
- Method 2 with shadows off isolates geometry motion (no shadow term can
change), and the robust mask confines it to where the wind model says it
should be.
- It does not prove the motion *looks* right — amplitude, rhythm and the
Clear/Rain/Storm table are the owner's call at the visual gate — nor does
the offline scene exercise weather other than Clear.

View file

@ -1,59 +0,0 @@
# Campaign VM — VM7 automated closeout
**Date:** 2026-08-23 · **Binary:** `99d5b2a6` (source = binary, gate-verified) ·
**Status:** every automated VM7 row PASSED (the matrix on its second attempt — see #422); the owner's visual gates (VM3,
VM6, VM7 matrix) and the merge to main are OWED and were not attempted.
Every row names its command, lane and artifact (the campaign's closeout rule).
| Row | Command | Lane | Result | Artifact |
|---|---|---|---|---|
| Complete Release gate | `pwsh ./tools/run-release-gate.ps1` | hermetic (54-project graph, locked restore) | **15,283 passed / 0 skipped / 0 failed** across 14 assemblies | `artifacts/release-gate/release-gate-summary.json` |
| Connected lifecycle/reconnect route, pack off | `tools/run-connected-world-lifecycle-gate.ps1 -SkipBuild` | Live (local ACE, `+Acdream`) | **PASS**; warning: 25 expected world-edge landblock misses (capped) | `logs/connected-world-gate-20260823-043414/report.json` |
| Connected lifecycle/reconnect route, High pack | `… -RenderPackPreset high` | Live | **PASS**; same expected warning | `logs/connected-world-gate-20260823-043951/report.json` |
| VM0 invariance on the final binary | `tools/run-offline-pixel-gate.ps1 -RenderPackPreset retail -BuildingDetailTextures $false -Uncapped` for HEAD and `-ExeOverride <base+normals exe>`, two captures each | offline, isolated settings, one tool | **robust diff 510 px** (all in the lifestone band, VM0's known dynamic); whole-frame mean \|Δ\| 0.0075 = the repeat floor | `docs/research/evidence/vm7/1f151242-pack-off-vs-base-normals-robust.png` |
| #422 characterisation | `tools/i422/loop-debugheap.ps1` (24 High + 6 retail-1080p runs under the debug heap), 16 attached runs, `tools/i422/loop-plain.ps1` (10 plain retail-1080p runs, rebuild first) | offline, Release | **1 fault in ~57 runs** — the one hit was the matrix's retail row (pack OFF, first launch after a build); 0/46 under a debugger, 0/10 plain; no WER/event trace → no stack yet. Pack-independent exit-time fault, ~2 %; owner's call whether it blocks the merge | `docs/research/evidence/vm7/i422-*.txt`; `docs/ISSUES.md` #422 |
| AR performance matrix, changed presets | `tools/run-atmospheric-performance-matrix.ps1 -PresetSet retail,low,medium,high -ResolutionSet 1920x1080 -FramePacing uncapped -SkipBuild` | offline, 45 s warm-up | **PASS** (second attempt; see below) | `artifacts/vm7-matrix-2/atmospheric-performance-matrix.md`, copy under `evidence/vm7/` |
The `base+normals` reference is main `6c79d35c` + the two patches recorded at
`docs/research/evidence/vm0/` (config-dir isolation; A2 terrain normals),
built in a throwaway worktree and run through HEAD's gate script — never
the old tool (VM0 §"three methods that did not work", method 1).
## Performance matrix
`tools/run-atmospheric-performance-matrix.ps1 -Out artifacts/vm7-matrix-2 -PresetSet retail,low,medium,high -ResolutionSet 1920x1080 -FramePacing uncapped -SkipBuild` on `fe56b6cf`, RX 9070 XT, 45 s warm-up, 2048-sample windows — **RESULT=PASS**; every pack preset inside its declared ceiling (CPU p50 0.15/0.25/0.35 ms, GPU p50 2/3.25/4.5 ms, resident 64/128/256 MiB). Copy: `evidence/vm7/atmospheric-performance-matrix-fe56b6cf.md`.
| Preset | CPU p50 / p95 / p99 ms | Receiver p50 / p95 / p99 ms | GPU p50 / p95 / p99 ms | Resident MiB | Result |
|---|---:|---:|---:|---:|---|
| retail | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 | PASS |
| low | 0.125 / 0.14 / 0.163 | 2.577 / 2.664 / 2.92 | 0.921 / 1.025 / 1.029 | 40.219 | PASS |
| medium | 0.134 / 0.154 / 0.189 | 2.557 / 2.661 / 2.964 | 1.033 / 1.06 / 1.17 | 74.09 | PASS |
| high | 0.135 / 0.147 / 0.174 | 2.413 / 2.469 / 2.835 | 1.235 / 1.277 / 1.295 | 114.056 | PASS |
The first attempt (`artifacts/vm7-matrix`, `621a0edf`) FAILED on its retail row: the client exited `-1073740940` (#422) on the first launch after a build — the pack presets of that attempt passed with the same numbers. That occurrence is what re-characterised #422 as pack-independent (see the issue).
## Register, roadmap, memory
- Register: IA-24 amended (the receiver lighting direction follows the
celestial shadow source while shadows render; authored-light fallback when
the gate is closed), IA-25 (foliage wind) and AP-232 (detail blend weight)
stand from their slices; no row retired at VM7.
- Roadmap: Campaign VM entry → "in flight, automated closeout complete;
owner gates and merge owed".
- Memory: `project_visualmaster_campaign.md` (digest + DO-NOT-RETRY) and
`feedback_pixel_diff_needs_repeat_floor.md`.
## What the owner gets to decide
1. **VM3** — the linear-light post stack (brief: plan §VM3 gate brief; the
neutral High preset is pixel-identical to pack-off on static content, the
non-neutral presets are darker and differently toned than before — the
curve table in the brief says by how much).
2. **VM6** — the wind (plan §VM6 Acceptance, six steps; Clear should read
"barely moving, alive", Rain/Storm "clearly windy, still not a flag";
the shadows follow; nothing moves indoors or pack-off).
3. **VM7** — the final pack-off / pack-on matrix, then the merge.
If any pack-on run ends with exit code `-1073740940`, #422 is back: run
`tools/i422/loop-debugheap.ps1` first.

File diff suppressed because it is too large Load diff

View file

@ -1,142 +0,0 @@
# Campaign CA — connected gate script (CA5, user-driven)
**Purpose:** live verification of the whole advancement chain against ACE
after CA1CA4 (`1fc64984`, `65430d4c`, `57818959`, `08b77e20`). Everything
below should be visible **without a relog** — that is the entire point of
the campaign.
**Character:** use a SCRATCH character (owner decision 2026-08-24 — XP and
skill credits will be spent, and the respec checks are destructive).
A fresh character is ideal: low attribute costs mean many cheap raises.
**Launch:** the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at
`127.0.0.1:9000`). No diagnostic env vars needed. Useful ACE console
helpers: `@ci <wcid>` to spawn gems, `@grantxp`, `@grantskillcredits`
(see `claude-memory/../memory/reference_ace_commands.md`).
Open the Character panel (F9 / toolbar) before starting; keep the vitals
bar visible throughout.
---
## 1. Attribute raise → derived skills + run speed (the original #431)
Prep: bank some XP (`@grantxp` if the scratch character is too poor).
Note current run speed by running a straight line; note the Run skill's
displayed value and one other Quickness-fed skill (e.g. Melee Defense).
1. Raise **Quickness** by 1.
- PASS: the attribute value updates when the server record lands (a
round trip, not a relog); **Run and every Quickness-fed skill row
update in the same moment**; XP remaining drops by the server's
accounting.
2. Raise Quickness repeatedly until the formula contribution crosses a
point (attribute current +2 → skill formula +1 for /2-divisor skills).
- PASS: skill rows tick up as the attribute crosses each threshold.
3. **Run before and after.** With `runrate_add_hooks` active on ACE the
server also re-broadcasts your movement speed mid-run.
- PASS: run speed visibly increases after the raise — at latest on the
next movement start. FAIL if speed only changes after relog.
4. While the raise is in flight (click and watch closely): the raise
buttons ghost momentarily and un-ghost when the record lands.
- PASS: brief ghost; no double-send on rapid double-click (the second
click does nothing).
## 2. Endurance/Self raises → vitals maxima (the single-record quirk)
Note max health / max stamina / max mana from the vitals bar.
1. Raise **Endurance** by 12 points.
- PASS: **max health AND max stamina both move** on the bar and the
panel (ACE only pushes a Health record; the client-side fan-out must
cover Stamina — research doc §4.1).
2. Raise **Self**.
- PASS: max mana moves.
3. Sanity: current values don't jump wrongly (regen keeps ticking
normally; watch ~10 s).
## 3. Direct vital raise + the retail raise-10 client bug (AP-73 CHECK)
1. Raise **Max Health** directly by 1.
- PASS: bar max + panel update on the record; XP debits.
2. **The deliberate failure case** — this resolves the narrowed AP-73 row.
Arrange XP so you can afford exactly ONE vital raise but not ten
(spend down; the cost curve is steep so this is easy). The retail
client bug ACE documents: the raise-10 button is enabled anyway.
Click **raise ×10**.
- EXPECTED from ACE: chat line "Your attempt to raise ... has failed."
and NO stat change.
- **RECORD: do the raise buttons stay ghosted afterward?**
- If they un-ghost on their own → note what un-ghosted them (a regen
tick counts as a quality change — that is retail-plausible and
AP-73 can then RETIRE with that mechanism recorded).
- If they stay ghosted until you close/reopen the panel → AP-73's
symptom confirmed; report it and we decide the fix against the
retail oracle (cdb on the live retail client if needed).
## 4. Skill raise
1. Select a TRAINED skill, raise ×1 and ×10.
- PASS: ranks/value update on the record; XP debits; the
"Your base <skill> skill is now N!" advancement line appears in the
SpewBox with the advancement color.
2. Confirm an attribute-less skill (e.g. **Salvaging**, if trained) shows
value = ranks-only progression and raises normally.
## 5. Train a new skill
Prep: ensure ≥ the DAT cost in skill credits (`@grantskillcredits`).
1. Select an UNTRAINED skill, click Train.
- PASS: the skill flips to Trained on the record; credits drop by the
DAT cost; chat: "<skill> trained. You now have N credits available."
2. NEGATIVE (silent-failure probe): nothing client-side should allow a
wrong cost, so simply confirm no double-send on rapid clicks and that
the button ghosts while awaiting.
## 6. Specialize / lower (SkillAlterationDevice)
Prep: `@ci` a **Gem of Enlightenment** (specialize) and a
**Gem of Forgetfulness** (lower) — wcids per ACE's db (ask the console
with `@acecommands` if unsure).
1. Use the Enlightenment gem on a TRAINED skill.
- PASS: retail confirmation dialog appears; on accept, the skill flips
to Specialized, credits drop, and the "You have succeeded
specializing..." notice lands in chat (the 0x028B
WeenieErrorWithString route).
- Also confirm DECLINING the dialog changes nothing.
2. Use the Forgetfulness gem: Specialized → Trained, then Trained →
Untrained.
- PASS: each step updates the panel on the record and refunds credits
per ACE's accounting; an untrained skill row returns to the
untrained section with formula-only value.
## 7. Respec-adjacent (as far as ACE supports)
If ACE's Enlightenment/attribute-reset paths are reachable on this server
(level requirements may block a scratch character — skip if so), exercise
one and confirm the client tracks every pushed record without a relog.
Otherwise mark N/A — the message shapes are identical to §6's, so §6
passing covers the client-side mechanism.
## 8. Regression sweep (5 minutes)
- Vitae/buff display still correct after raises (buff an attribute; panel
shows effective + base pair; skill values include the buff through the
formula).
- Logout/login: everything you raised persists and PlayerDescription
agrees with what the live records showed (any mismatch = a parser bug —
report exact numbers).
- Ordinary play smoke: run, jump, cast, fight one mob — nothing about
movement feel changed outside the raises.
---
## Report back
Per section: PASS/FAIL plus anything odd. The three answers that matter
most:
1. §1.3 — did run speed change live?
2. §2.1 — did max STAMINA move on an Endurance raise?
3. §3.2 — the AP-73 ghost question (un-ghosted by what / stayed stuck?).

View file

@ -1,797 +0,0 @@
# Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E)
**Status:** RESEARCH COMPLETE 2026-08-24. No production code changed in this
slice. Findings feed CT2CT6 (`docs/plans/2026-08-24-character-panel-parity-campaign.md`).
Method: temporary `Assert.Fail` probe tests (deleted before commit; the
pattern is preserved in `tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs`
and its sibling `CharacterPanelLiveDatTests.cs` written by this slice) against
the installed DAT set (`%USERPROFILE%\Documents\Asheron's Call`), driven
through `LayoutImporter.ImportInfos` / `ElementInfo`
(`src/AcDream.App/UI/Layout/ElementReader.cs`,
`src/AcDream.App/UI/Layout/LayoutImporter.cs`).
## 1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E)
All header elements are DUPLICATED — the imported 0x2100002E tree carries
two structurally identical copies of the whole header block, one reached
through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 >
0x10000230`) and one through the Skills page chain (`... > 0x1000022C >
...`). **The two copies are geometrically and stylistically IDENTICAL**
this is the "duplicated stat-management branches" quirk documented on
`PrepareSkillScrollbar` in `CharacterStatController.cs`. **Caveat: this
"identical" claim rests on the deleted probe tests, not the committed
pins.** `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
only asserts font/color/margin equality across the two copies (and the
2-count) — it does not assert X/Y/W/H geometric equality between them.
Treat "geometrically identical" as probe-session observation, not a
pinned fact, until a future slice adds a geometry-equality assertion.
**Coordinate frame:** every `X,Y` below is **parent-relative**, not
page-relative — e.g. Name `0,0` means the top-left of its immediate
parent container (`0x10000230`), not the top-left of the Attributes/
Skills page. Cross-reference
`docs/research/2026-06-25-character-window-faithful-spec.md` for the
page-relative numbers if you need the header block's position within
the page itself; do not mix the two frames when placing elements.
| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins |
|---|---|---|---|---|---|---|---|---|---|
| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 |
| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 |
| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | **white (1,1,1,1)** | false | 0 |
| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | **(1, 0.9490196, 0.49803922, 1)** | **true** | 0 |
| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 |
| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — |
| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 |
| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 |
| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 |
| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 |
Header block container (0x10000230) is 300×110 inside the shared prototype
layout 0x21000045 — the character content column itself is only 230px wide
(name/heritage/PK/XP all live in that 230px column), with a 5px vertical
divider (0x10000239, X=230, W=5) separating it from the level box
(X=235..300, matching the plan's "Level area (65,50)" spec).
**Confirmations vs. the owner report / plan:**
- Item 4 (PK line pure white): CONFIRMED — `0x10000233` FontColor is
exactly white, not the "color off" state the owner reported. The bug is
purely in `CharacterStatController.Bind`'s runtime color choice (`Body`
= parchment `(0.92,0.90,0.82,1)`), not a DAT-reading gap. CT4 must switch
the PK-line color to `Vector4.One`.
- Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is
`(1, 0.9490196, 0.49803922, 1)` (a pale gold, ~RGB 255/242/127) **with
Outline=true**. `CharacterStatController.Gold` is currently
`(1, 0.82, 0.36, 1)` (a deeper orange-gold) with no outline applied. CT4
should read the DAT FontColor + Outline directly instead of hand-picking
a runtime color, matching how `LevelId`'s dat FontDid is already honored.
## 2. Stat list (0x1000023D) + row templates
`0x1000023D` (Type 5 ListBox) is duplicated the same harmless way as the
header (once per Attributes/Skills page chain, identical geometry both
times): `X=0 Y=112 W=300 H=160`, `ScrollbarElementId=0x1000023E`
(scrollbar at `X=281 W=16 H=160`, i.e. always reserved, whether or not it's
shown). Its authored `TemplateList` (dat property 0x64) names FIVE
same-layout-family entries, all in **LayoutDesc 0x21000045**:
```
0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C
```
These are NOT reachable by walking `ImportInfos(dats, 0x21000045u)`'s built
tree — `LayoutImporter.ImportInfos` intentionally filters out same-layout
template-list targets (the `#375` fix documented in
`LayoutImporter.ImportInfos`'s own comment: "retail never instantiates a
template-list element as a live widget… building them here parked two live
prototype rows… over and outside the framed panel"). The correct read path
— and the one CT5 must use — is the **targeted single-root overload**:
`LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId)`, the same
seam `UiTemplateListBox`'s `TemplateResolver` already uses for other
authored row templates.
Dumping all five with that overload:
### 0x10000248 — the ONE shared data-row template (icon + name + value)
```
Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20
StateMedia[Normal] File=0x06004CC2 DrawMode=1
StateMedia[Highlight] File=0x06000F93 DrawMode=1
Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime)
Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white
Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white
```
This is the single row template used for BOTH the attribute rows AND the
skill rows (retail's `gmAttributeUI`/`gmSkillUI` share it). Ground truth
for item 1 (icon alignment) and item 2 (value-column gutter):
- **Icon: 20×20, flush at the row's left edge (X=0), full row height.**
Current code (`CharacterStatController.IconSize = 16f`,
`RowPadX = 4f`) draws a 16×16 icon at X=4 — smaller AND offset from
retail's flush-left 20×20. This is the "icons misaligned" bug (item 1).
- **Name column: X=25, W=150 (fixed pixel widths, not a width fraction).**
Current code computes `nameX = RowPadX + IconSize + IconGap` (4+16+6=26,
off by one from retail's 25) and `nameW = width * 0.60` (a
content-relative fraction retail does not use at all — retail's name
column is a FIXED 150px regardless of the 282px row width).
- **Value column: X=175, W=100, right-justified.** Value's right edge
sits at X=275. **The row template itself is 282px wide, so there is a
7px gap between the value's right edge and the row's own right edge**
— this is the "authored margin between the value column and the
border" the owner reported (item 2), confirmed as exactly 7px at the
row-template level.
- **The bare authored rectangles** (no derived arithmetic — see the
caveat below): ListBox `0x1000023D` is `X=0 Y=112 W=300 H=160`; its
scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is
`W=282`; the value column inside the row is `X=175 W=100`,
right-justified, 7px from the row's own right edge (`282 - (175+100) =
7`).
- **These numbers do NOT compose into a tidy "gutter" story — do not
infer one.** `300 - 281 = 19`, not the row's 282px width's complement
(`300 - 282 = 18`); and the 282px row actually OVERLAPS the 281px
scrollbar band by 1px (`281 < 282`). An earlier draft of this doc
described the row as "inset by 18px to clear an always-reserved
scrollbar gutter" and summed 18+7=25 — that decomposition does not
close against the authored numbers above and is **inference, not
fact**; withdrawn. CT5 must implement the authored numbers directly
(row width 282 — matches the existing `SkillContentWidth = 282f`
constant already in the code; value column X=175 W=100; scrollbar
X=281 W=16), never a derived `listWidth - 18` or similar formula.
- Row background: `Normal` state file `0x06004CC2`. **The description
"generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED**
— no cross-reference sweep for other consumers of `0x06004CC2` was run
this slice; only the file id itself, as authored on this specific
template, is pinned. `Highlight` state file **`0x06000F93`**.
### SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged
Retail's selected attribute/skill row draws the row template's Highlight
state media — `0x06000F93`, not `CharacterStatController`'s current
`0x06001397u`. This is no longer a hedge; the decomp confirms the
mechanism end to end:
- `gmAttributeUI::UpdateSelection @0x0049DEE0` calls
`SetState(selected ? 6 : 1)` on the row.
- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row
element instantiated from template `0x10000248` — the exact template
this slice dumped, whose Highlight-state file is `0x06000F93`.
- State `6` IS `UIStateId.Highlight` — so the selected row draws
`0x10000248`'s own `Highlight` media, not a separately-chosen sprite.
`CharacterStatController.cs`'s comment near lines 111113 ("matches
retail... sprite 0x06001397 visual intent") is **falsified for this
element**. CT5 must correct `RowHighlightSprite` to `0x06000F93` for the
STAT rows and should reconsider `UseSelectionBars`/`HighlightBg`, which
currently emulate the wrong sprite's art (a translucent gold tint tuned
to look like `0x06001397`'s dark bars, not `0x06000F93`'s actual look).
**`0x06001397` is not a phantom constant — it is legitimate ELSEWHERE.**
The spellbook row prototype `0x10000343` has a separate selected-overlay
CHILD element `0x10000342` whose media IS `0x06001397`
(`UIElement_UIItem::SetSelectedState @0x004E1240` mechanism — a
different code path from `InfoRegion::SetState`, and a different visual
composition: an overlay child, not a state-swap on the row itself). CT5
must correct the STAT rows ONLY and must NOT touch `SpellbookRowStyle.cs`
or its tests — `0x06001397` is correct there.
### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions
```
Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90
Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86
Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98
Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89
```
These are single full-width caption bars (no icon/name/value split), 280px
wide (2px narrower than the data row — no scrollbar-gutter inset needed
since they never scroll independently). Their sprites are an EXACT match
for the existing constants already in `CharacterStatController.cs`:
| Constant | Value | Probe file | Match |
|---|---|---|---|
| `SkillHeaderSpecializedSprite` | 0x06000F90 | 0x10000249 | YES |
| `SkillHeaderTrainedSprite` | 0x06000F86 | 0x1000024A | YES |
| `SkillHeaderUntrainedSprite` | 0x06000F98 | 0x1000024B | YES |
| `SkillHeaderUnusableSprite` | 0x06000F89 | 0x1000024C | YES |
These four are correctly ported already; no CT5 work needed here.
### Row-height divergence (CT5 gold, found this fix round)
The row template above authors `H=20` (line `Id=0x10000248 Type=3
(container) X=0 Y=0 W=282 H=20`, already pinned by
`CharacterPanelLiveDatTests.AttributeRowTemplate_...`). Current code
matches this for skill rows (`CharacterStatController.SkillRowHeight =
20f`) but NOT for attribute rows, which use a separate
`CharacterStatController.RowHeight = 22f` constant. CT5 must fix the
attribute-row path to 20px; there is no authored basis for 22 anywhere
in the row template.
### Row instantiation + icon-DID anchors (CT5 gold, found this fix round)
`InfoRegion::InfoRegion @0x004F1450` instantiates each stat row via
`AddItemFromTemplateList(listBox, 0, ...)` — template index **0**, i.e.
`0x10000248`, the shared data row confirmed above — and binds
`0x1000012A` (label), `0x1000012B` (value), `0x10000129` (icon) via
`UIRegion::SetImageByDID(icon, did, 3)` (icon draw mode 3). Per-attribute
icon DIDs come from `DBObj::GetDIDByEnum(statEnum, category
0x10000002)` in `gmAttributeUI::PostInit @0x0049DB70` — a THIRD consumer
of the `GetDIDByEnum` master-map mechanism documented in §5 below
(alongside the title EnumMapper/StringTable pair and `RetailKeyNames`),
which is enough precedent that CT2/CT5 should factor a shared
`GetDIDByEnum(enumValue, category)` helper instead of hardcoding a third
independent DID pair.
`gmSkillUI::RebuildSkillList @0x0049C3A0` adds template indices **14**
(`0x10000249..0x1000024C`) for the section-header captions, confirming
the section-header order already pinned by
`SkillSectionHeaderTemplates_MatchExistingSpriteConstants` above:
Specialized, Trained, Untrained, Unusable.
## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E)
`0x10000539` (Type 0x10000046, the Titles page container) is NOT
duplicated like the Attributes/Skills content — it appears once, as a
direct child of the tab-control root `0x10000227` (siblings with the
Attributes/Skills tab buttons and the Titles tab button `0x10000538`).
Geometry: `X=0 Y=25 W=300 H=575` (fills the window below the 25px tab bar).
| Element | Id | X,Y | W×H | Notes |
|---|---|---|---|---|
| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption |
| **Current display title text** | **0x1000052F** | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white |
| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 |
| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption |
| **Title ListBox** | **0x10000532** | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 |
| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared `RetailScrollbarChrome` media (thumb/up/down ids identical to the base skin pinned by `ScrollbarSkinLiveDatTests`) |
| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 |
| **"Set as Display Title" button** | **0x10000535** | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=**Ghosted** (matches the plan's `UpdateButtons` ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/**Ghosted** states each |
### Title row template — LayoutDesc 0x2100005E, element 0x10000536
```
Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24
StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3
StateMedia[Highlight] File=0x06001AAF DrawMode=1
Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white
```
A single-line text row, no icon column (titles have no per-row icon in
retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the
ListBox content width exactly (`0x10000532` is 270 wide, with its
scrollbar `0x10000533` living OUTSIDE that width at X=280 — unlike the
stat list, the title row template does NOT need its own internal
scrollbar-gutter inset because the ListBox width itself already excludes
the scrollbar column).
Same targeted-root import gotcha as the stat templates applies here:
`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout
overload) returns the SAME element `0x1000052D`, but does NOT surface
`0x10000536` as a reachable child. **`0x1000052D` is not a throwaway
container** — per the `BaseElement`/`BaseLayoutId` table in
`docs/research/2026-06-25-character-window-faithful-spec.md` (line
~19), `0x1000052D` is the authored `BaseElement` that the Titles page
root `0x10000539` inherits its content from (`0x10000539`'s
`BaseLayoutId` is `0x2100005E`, `BaseElement` is `0x1000052D`) — it is
the real authored Titles-page content, just reached by a different path
than the live-mounted tree. Use
`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` (the
targeted single-root overload) to reach the row template `0x10000536`
underneath it.
## 4. Window min/max constraints
### Character window (0x2100002E) root
`LayoutImporter.ImportInfos(dats, 0x2100002Eu)` returns element
`0x10000227` (Type 0x8, TabControl) as the tree root — this IS the
top-level element retail's `RetailUiRuntime.MountCharacter()` mounts via
`RetailWindowFrame.Mount(..., layout.Root, ...)`. **It authors NO
MinWidth/MinHeight/MaxWidth/MaxHeight properties** (dat properties
0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of `MinW/MinH/MaxW/MaxH`
blank for 0x10000227 and every element under it, including the footer,
header, and Titles page).
### Chat window (0x2100006F) root, for comparison
`LayoutImporter.ImportInfos(dats, 0x2100006Fu)` returns element
`0x10000600` (Type 0x10000050, a self-contained "window" element that
directly includes its own dragbar (Type 2), border frame (Type 3), and
FOUR resize-grip corners (Type 9) as children — none of which the
character layout's root has). It DOES author constraints:
**MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.**
### Correction to the plan
The plan's "Already in-tree" section states: *"The character window
registers with `DatConstraintSource` — authored min/max plumbing exists in
`RetailWindowFrame`; Y-resize for this window and the list-scrollbar
contract do not."* Read literally this implies the character window's
`RetailWindowFrame.Mount` call already sets `DatConstraintSource`. **It
does not.** `RetailUiRuntime.MountCharacter()` (`src/AcDream.App/UI/RetailUiRuntime.cs`,
~line 4043) constructs `RetailWindowFrame.Options` with `ResizeY = true`,
`ResizableEdges = ResizeEdges.Bottom`, `ConstrainResizeToParent = true`
but **no `DatConstraintSource`, `MinHeight`, or `MaxHeight` field at
all**, unlike e.g. `MountSideVitals()` (~line 1474) which explicitly sets
`DatConstraintSource = info` from its own imported root. This is
consistent with what CT1 also found in the DAT itself: 0x2100002E's root
authors no size constraints to plumb through in the first place — chat's
window-frame elements are its own self-contained LayoutDesc, while
0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome
supplied by a separate mechanism.
Treat the "Already in-tree" plan bullet as **inaccurate**: start CT6
from the decomp for the window-frame class instead of assuming the
wiring is already 90% done. The paragraph below replaces this doc's
earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the
verified mechanism.
### Verified resize mechanism (2026-08-24, supersedes the hypothesis above)
`UIElement::ResizeTo @0x00463C30` clamps ONLY via element attributes —
`0x3C` (clamp-max-height), `0x3E` (clamp-min-height), `0x3D`
(clamp-max-width), `0x3F` (clamp-min-width) — read off `this`, the
element actually being resized. A decomp-wide grep for writers of those
four attributes turns up NOTHING: no runtime code ever sets them at
runtime. The clamp source is exclusively **authored DAT properties on
whichever element `ResizeTo` is called against, full stop.** There is no
hardcoded `SetMinSize` call anywhere in the class hierarchy; the earlier
"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a
guess and is WRONG.
The element `ResizeTo` is called against is not `0x2100002E`'s own root.
Per `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`
(lines ~83-107) and the slot table in
`docs/research/2026-08-11-fa-panel-structure.md` (row for `0x1000018E`),
retail's Character/Skills tab content is one child slot inside the
SHARED `gmPanelUI` host, LayoutDesc `0x2100006E`:
```text
gmPanelUI host 0x100005FE 310 x 372
content parent 0x10000180 300 x 362 (anchored all edges)
Character/Skills slot 0x1000018E panel id 11, 300 x 362
top-center Dragbar 0x1000065C Type 2
bottom-center Resizebar 0x10000660 Type 9
```
`gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall into
`UIElement::ResizeTo` — the HOST is what gets resized (via its
Resizebar), not `0x2100002E`'s content root; the content root's own
absent MinHeight/MaxHeight (confirmed above) is therefore consistent
with retail's actual mechanism, not evidence of a missing DAT property.
**NOT PROBED by CT1.** The host elements above (`0x100005FE`,
`0x10000180`, `0x1000018E`, `0x1000065C`, `0x10000660`) were read from
the cited pseudocode doc, not re-probed live against the installed DAT
this session. CT6's first step is to probe those host slots directly
(min/max + resize authoring) before porting anything, then read
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0` verbatim for the
drag-time clamp application.
**Size tension for CT3/CT6 to resolve.** `0x2100002E`'s own root is
authored **300×600** (the Titles page alone is 300×575, plus the 25px
tab bar = 600 — §3), but retail mounts that content into the host's
**300×362** slot (`0x1000018E`). CT3's title ListBox height (`0x10000532`
is 270×455 per §3) and CT6's resize contract both assume a taller
available area than the host slot's authored 362px. This doc does not
resolve which number governs at runtime (scroll-clipped content inside a
fixed slot vs. the slot itself growing to accommodate) — CT6 must
resolve it from the decomp before implementing the resize contract, not
infer it from either number in isolation.
## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`)
### Decomp chain (named-retail, `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
`CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0` does **not**
read a StringTable directly. It goes through TWO independent
enum-mapper indirections:
1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static
3-arg overload @`0x0041ac40`) — internally calls
`DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the
**title EnumMapper object's DID**, then dispatches on
`MasterDBMap::DivineType` (0x24 = EnumMapper) to call
`EnumMapper::GetString(titleId, &rawName)` on it, giving a raw
canonical string name (NOT yet localized/hashed).
2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact
as `DatStringResolver.ComputeHash` — see its own citation of
`compute_str_hash @ 0x00413110`).
3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)`
(`@0x0042c760`) — internally calls
`DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the
**title StringTable's DID**.
4. `StringInfo::GetString(&info)` (`@0x0042e760``InqString`
`StringTableMetaLanguage::UnescapeString`) resolves the final localized
text — the same `StringTable.Strings[hash]` lookup
`DatStringResolver.Resolve(tableId, stringId)` already performs.
`DBObj::GetDIDByEnum(enumValue, category)` (`@0x004153a0`
`DBCache::GetDIDFromEnum @0x00413940`) is itself a **two-level indirection**
through a master map object (`this->m_MasterMapID`): look up `category`
in the master map to get an intermediate category-map DID, then look up
`enumValue` in THAT map to get the final DID. This is the exact same
mechanism already ported (empirically, not by name) as `RetailKeyNames`'
`0x2300000A`/`0x2300000B`/`0x23000007` constants
(`src/AcDream.App/UI/Layout/RetailKeyNames.cs`, citing "`DBCache::GetDIDFromEnumStatic`
category 4") — this slice confirms those three constants ARE exactly the
category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so
the existing `RetailKeyNames` port is independently cross-validated by
this investigation.
### Live-DAT resolution (verified end-to-end this session)
**The master-map / category-map tables and the `RetailKeyNames`
cross-validation below are UNPINNED probe output** — they come from the
same deleted `Assert.Fail` probe tests as the rest of this doc and are
not backed by a committed `InstalledDatFact` assertion (unlike the
`TitleStringTable_ResolvesWarMageEndToEnd` pin, which DOES commit the
final two DIDs and the end-to-end string resolution). Treat the
category-4 dump and the `RetailKeyNames` match column as this session's
observation, re-derivable from the DAT but not regression-guarded.
`DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`,
file-type byte `0x25`) is the object type both master and category maps
use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the
installed dat) is the flat id→string table type.
**Master map, DID `0x25000000`** (`ClientEnumToID`/`ClientEnumToName`,
22 entries) — the categories relevant here:
| category enum | name | category-map DID |
|---|---|---|
| 1 | EMAPPER | 0x25000001 |
| 4 | STRINGTABLE | 0x25000004 |
**Category 1 (EMAPPER) map, DID `0x25000001`** — relevant entry:
| enum | name | DID |
|---|---|---|
| 0x10000006 | CharacterTitle | **0x22000041** |
**Category 4 (STRINGTABLE) map, DID `0x25000004`** — full dump (12 entries),
confirming the `RetailKeyNames` constants along the way:
| enum | name | DID | cross-check |
|---|---|---|---|
| 0x00000003 | KeyMap | **0x23000007** | = `RetailKeyNames.DelimiterTableId` ✓ |
| 0x00000004 | KeyNameOverride | **0x2300000A** | = `RetailKeyNames.KeyNameTableId` ✓ |
| 0x00000005 | MetakeyNameOverride | **0x2300000B** | = `RetailKeyNames.MetaKeyNameTableId` ✓ |
| 0x10000007 | CharacterTitle | **0x2300000E** | (this slice's target) |
| 0x10000001 | UI | 0x23000001 | |
| 0x10000002 | UI_Pregame | 0x23000002 | |
| 0x10000003 | Preference | 0x23000003 | |
| 0x10000004 | UI_Options | 0x23000004 | |
| 0x10000006 | Options | 0x2300000D | |
| 0x00000002 | Calendar | 0x23000006 | |
| 0x00000006 | CommandSetup | 0x2300000C | |
| 0x00000007 | ActionDescription | 0x23000005 | |
| 0x00000008 | ServerEngine | 0x23000010 | |
So: **the title EnumMapper is DID `0x22000041`; the title StringTable is
DID `0x2300000E`.**
### End-to-end verification (ACE's `CharacterTitle` enum, `WarMage = 13`)
```
EnumMapper(0x22000041).IdToStringMap has 873 entries, including:
titleId 0 -> ID_CharacterTitle_Invalid
titleId 1 -> ID_CharacterTitle_Adventurer
titleId 5 -> ID_CharacterTitle_Life_Mage
titleId 13 -> ID_CharacterTitle_War_Mage
titleId 14 -> ID_CharacterTitle_Wayfarer
ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05
DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage"
```
Byte-exact confirmation the chain is understood correctly end to end —
titleId 13 round-trips through the EnumMapper canonical-name lookup, the
retail hash function, and the StringTable localization lookup to produce
exactly "War Mage".
### What CT2 needs to port
1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category
map → target DID) — CT2 can either hardcode the two resolved DIDs
(`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the
way `RetailKeyNames` hardcodes its three) or port the two-level
indirection generically. **A THIRD consumer has now appeared** (found
this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves
per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category
0x10000002)` — see the "Row instantiation + icon-DID anchors" note in
§2. With `RetailKeyNames` (category 4) and the title chain (categories
1 and 4) already hardcoding resolved DIDs, this third independent
category (`0x10000002`) is the point where CT2/CT5 should factor a
shared `GetDIDByEnum(enumValue, category)` helper instead of adding a
fourth ad-hoc hardcoded pair.
2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already
readable via `dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>`).
3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code).
4. `DatStringResolver.Resolve(0x2300000Eu, hash)` (already exists, no new
code) for the final localized display string.
No new DAT-reading primitives are required — `EnumMapper`/`EnumIDMap` are
already exposed by `DatReaderWriter.DBObjs`, and `DatStringResolver`
already does steps 34 for other consumers.
## Corrections to the plan (summary)
1. **Window constraints are NOT already 90% wired — and the clamp
mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree"
bullet claims `DatConstraintSource` registration for the character
window; the actual `MountCharacter()` call sets no such field, and the
DAT layout itself authors no MinHeight/MaxHeight on its root to source
one from even if it were wired. `UIElement::ResizeTo @0x00463C30`
clamps only via element attributes `0x3C``0x3F`, which nothing writes
at runtime — the clamp source is always authored DAT properties on the
resized element, full stop, and the resized element is the SHARED
`gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'s
own root. See §4's "Verified resize mechanism" for the full chain and
the unresolved 300×600-vs-300×362 size tension CT6 must still resolve.
2. **The row-template elements are not walkable via the normal
`ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic
deliberately excludes same-layout template-list targets from the built
tree. CT5 must use `ImportInfos(dats, layoutId, elementId)` (the
targeted single-root overload) to read `0x10000248`
(`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) —
documented here so CT5 doesn't waste a cycle rediscovering the same
"NOT FOUND" dead end this slice hit first.
3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The
DAT's row-template Highlight state is `0x06000F93`, reached via
`gmAttributeUI::UpdateSelection`'s `SetState(6)`
`InfoRegion::SetState` on the row itself; the current constant in
`CharacterStatController.cs` is `0x06001397`, which belongs to a
DIFFERENT mechanism (the spellbook row's selected-overlay child,
`UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows'
`RowHighlightSprite` to `0x06000F93` and must NOT touch
`SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full
anchor chain.
4. Everything else in the plan's "Retail recon" section (the Titles page
element roster, the header element ids, the PostInit binding order)
checks out exactly against the live DAT — no other corrections.
5. **Several findings above are probe-session observations, not
committed pins** — flagged this fix round so CT2CT6 don't cite them
as regression-guarded facts: the header block's "two copies
geometrically identical" claim (only fonts/colors are pinned, not
full geometry — §1), the `0x06004CC2` "generic panel chrome"
characterization (§2), and the master-map/category-map dump plus the
`RetailKeyNames` cross-validation table (§5). The title chain's final
two DIDs and end-to-end string resolution ARE pinned
(`TitleStringTable_ResolvesWarMageEndToEnd`).
## §CT6 — the shared-host resize clamp (2026-08-25 live probe)
**Verdict: the resize clamp source is the shared `gmPanelUI` host
(`0x100005FE` in LayoutDesc `0x2100006E`), not `0x2100002E`'s own root
and not the Character/Skills slot `0x1000018E` either.** Probed with a
temporary test dumping layout `0x2100006E` via both the whole-layout
walk and the targeted single-root overload (deleted before commit; the
pattern is preserved by the committed pin
`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`).
### Host element `0x100005FE`
```
Type=0x1000002F (gmPanelUI) X=0 Y=0 W=310 H=372
MinWidth=310 MaxWidth=310 (fixed — no horizontal Resizebar authored)
MinHeight=372 MaxHeight=1000
17 children, including (all DIRECT children of the host, siblings of
the content parent, not nested under it):
0x10000180 content parent Type=3 X=5 Y=5 W=300 H=362
0x1000065C top-center Dragbar Type=2 X=5 Y=0 W=300 H=5
0x10000660 bottom-center Resizebar Type=9 X=5 Y=367 W=300 H=5
(+ 14 border/corner chrome pieces, 0x10000653-0x10000662)
```
`MinHeight == 372 == the host's own authored default height`: retail's
Character/Skills window can only be resized TALLER (up to 1000px), never
shorter than its own authored default — this IS the "resizable in Y down
to an authored minimum" the owner reported; 372 is that floor, not an
arbitrary smaller number.
The Character/Skills slot `0x1000018E` (the SAME structural role CT1
probed for `0x2100002E`'s standalone root `0x10000227`, but reached
through the shared host this time) itself authors **no** MinWidth/
MinHeight/MaxWidth/MaxHeight — confirming the clamp is exclusively the
HOST's, not layered again on the slot:
```
0x1000018E Type=8 (TabControl) X=0 Y=0 W=300 H=362
Min=(null,null) Max=(null,null)
children: 0x10000228/29 (tab buttons), 0x1000022A (close button),
0x10000538 (Titles tab), 0x10000539 (Titles page),
0x1000022B/2C (Attributes/Skills pages) — same ids `0x2100002E`
imports, reached here via `0x1000018E`'s BaseElement inheritance
from `0x10000227` (same mechanism CT1 §3 documents for the Titles
row template's `0x1000052D`/`0x10000536` pair).
```
### Decomp chain confirming which element the drag clamp applies to
- `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`: `eax_1 =
this->vtable->GetParent()` then `UIElement::StartResizing(eax_1,
border, x, y)` — the drag state (`m_DragStartWidth/Height`,
`m_currentBorder`) is stashed on the RESIZEBAR'S PARENT, confirmed
live to be the host `0x100005FE` (§ above), not the content parent.
- `UIElement::StartResizing @0x0045fca0`: pure state setup
(`m_DragStartX/Y/Width/Height`, `m_currentBorder`) on `this` — no
clamp read here.
- `UIElement::MouseResizeElement @0x00461130`: the actual per-mouse-move
resize application. Reads `GetAttribute_Int(this, 0x3F)` (min width),
`0x3D` (max width), `0x3E` (min height), `0x3C` (max height) — all off
`this`, the SAME element `StartResizing` was called against. Since
that element is the host (per the GetParent() call above), the host's
own authored 0x3C..0x3F values are what govern every live drag.
This matches — and completes — the "Verified resize mechanism" section
already in this doc (`UIElement::ResizeTo @0x00463C30`'s equivalent
clamp for the programmatic path): both the interactive drag
(`MouseResizeElement`) and the programmatic call (`ResizeTo`) read
0x3C..0x3F off the SAME element, and that element is always whichever
one is actually being resized — the host, never the character content
root or the slot.
### Production wiring (`RetailUiRuntime.MountCharacter`)
`MountCharacter` now imports `ElementInfo? hostConstraint =
LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu)` (the same
targeted single-root overload CT1 established for row templates) and
passes it as `RetailWindowFrame.Options.DatConstraintSource`.
**CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25):** this section
originally claimed the NineSlice chrome inset (`2 *
RetailChromeSprites.Border` = 10px) "is added automatically by
`RetailWindowFrame.ResolveConstraint`, giving the mounted outer frame
MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG.
Host `0x100005FE` is not a bare content element our own NineSlice
wrapper adds chrome to — per the geometry dumped above, it IS retail's
own complete outer window frame: 5px bevel + the 300×362 content parent
(`0x10000180`) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values
are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px
inset on top of an already chrome-inclusive source double-counted the
bevel: the mounted window's clamp said MinWidth=320 while its actual
mounted outer width was only 310 — the window opened already violating
its own minimum, silently "fixed" at runtime only because
`RetailWindowManager.ResizeTo`'s main-panel geometry sync forcibly
widened it to 320 despite `ResizeX=false`, which would have produced a
visible 15px right-bevel seam against the other eight main panels
sharing that sync.
**Fix:** a new opt-out,
`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` (default
`false`, preserving every other window's existing content-plus-chrome
behavior), tells `ResolveConstraint` to apply a chrome inset of 0 when
the DAT source is itself already the outer frame. `MountCharacter` sets
it `true` for `hostConstraint`. The mounted outer clamps are now EXACTLY
the host's four raw values, no composed arithmetic: **MinWidth=
MaxWidth=310, MinHeight=372, MaxHeight=1000.** A new mount-time
invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer
extent falls outside its own just-computed clamp) guards against this
class of bug recurring for any window this path mounts.
`ResizeX=false`/`ResizableEdges=Bottom` (already correct in the
pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring
exactly — no change needed there.
### Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25)
The "Size tension" flagged earlier in this doc (§4, "Correction to the
plan") — `0x2100002E`'s own root authored 300×600 vs. the host slot's
300×362 — is now resolved: **372px outer (362px content) is the number
that governs the MOUNTED DEFAULT**, and 600 is the content's own
authored design canvas that retail scroll-clips into the much smaller
host slot, never the size the window actually opens at. Concretely:
`0x2100002E`'s 300×600 root exists because its Titles page alone is
authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is
real, authored geometry, and the Titles page correctly stretches to fill
whatever height the mounted window offers via its own `LayoutPolicy`
(confirmed below). But retail never displays that full 600px canvas at
once outside of the Titles tab's own internal scroll: the shared
`gmPanelUI` host's content parent (`0x10000180`) is fixed at 300×362,
and 372 (362 + the 10px chrome bevel) is exactly the host's own
authored MinHeight — i.e., retail's Character/Skills window OPENS at
its own resize floor and can only be dragged taller, never shorter.
Pre-fix, `MountCharacter` left `Options.ContentHeight` unset, so it fell
back to `content.Width`/`content.Height` — the raw 600px canvas — giving
a stale 610px (600 + 10 chrome) mounted default that was never retail's
actual opening size and was 238px taller than the true floor. Fixed by
setting `Options.ContentHeight = 362f` explicitly (the same host
content-parent height this section already probed and cited, not a new
number). At the corrected 372px default, the authored page composition
(header 112px + list 160px + divider + footer) is the true 362px design;
the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px
list immediately, so the stat list's scrollbar is active from the moment
the window opens — this is retail-correct (see §S2 below for what
"active" means given the corrected `HideWhenDisabled` finding), not a
regression introduced by the fix.
### Titles-page list reflow
The Titles page's own container (`0x10000539`) and its ListBox
(`0x10000532`) both already carry a REAL authored `LayoutPolicy` in the
committed/installed layout (live-verified: `LayoutPolicy is not null`
for both) that correctly stretches with the mounted content's height —
no code was needed to make the PAGE itself reflow.
**CORRECTED (CT6 fix round, S3, 2026-08-25):** this section originally
went on to claim "the only missing piece was the ListBox's own
compatibility fallback (`Anchors = Left|Top|Bottom`, engaged only when
`LayoutPolicy is null` — a no-op on the real DAT, but needed for
synthetic/test layouts...), added in `CharacterTitlesController.Bind`."
That framing was wrong: both `0x10000532` and `0x10000539` author
`HasOriginalParentSize=true` (the field `DatWidgetFactory` gates
`LayoutPolicy` assignment on), confirmed both on the real installed DAT
and the committed fixture — `LayoutImporter`/`DatWidgetFactory` ALWAYS
assigns a real `LayoutPolicy` to these elements, so the `if
(listBox.LayoutPolicy is null)` branch never ran anywhere, not even in
the "synthetic/test layouts" case it was written to cover. It was dead
code, not a harmless no-op fallback. Deleted from
`CharacterTitlesController.Bind`; a new
`CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize`
pin asserts `HasOriginalParentSize` on both `0x10000532` and `0x10000539`
to guard the deletion against future DAT drift.
### Correction to the scrollbar-visibility finding (S2, 2026-08-25)
The CT6 landing notes (in the campaign plan ledger) claimed
`UiScrollbar`'s own `IsPresentationVisible`/`IsModelDisabled` "already
draw the correct full-track 'disabled' thumb when content fits
(`HideWhenDisabled` defaults false)." That had the authored default
BACKWARDS. Both `0x1000023E` (the shared Attributes/Skills list
scrollbar) and `0x10000533` (the Titles list's own scrollbar) author
property `0x79` (`HideWhenDisabled`) **TRUE** in the committed fixture —
verified directly against `tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json`,
property `"121"` (=0x79) carries `"BoolValue": true` on both elements. A
fitting list HIDES the bar entirely (`IsPresentationVisible` false); it
does not leave a full-track "disabled" thumb on screen. The CODE in
`CharacterStatController.RebuildActiveList` was already correct —
`.Visible = true` only keeps the bar bound in the tree, and
`IsPresentationVisible` is the actual show/hide computation — only the
description was wrong. Given the S4 correction above (mount default is
now the compact 372px floor), the practical consequence is: the stat
list's scrollbar is VISIBLE and interactive from the moment the window
opens (rows overflow at the default), and DISAPPEARS once the window is
grown enough that all rows fit — the opposite of what the uncorrected
description implied.
### A latent anchor-baseline bug this slice surfaced and fixed
`CharacterStatController.RebuildActiveList`'s (and, before CT6, only the
Skills tab's) `UiScrollablePanel` viewport is constructed with `Height =
statList.Height` **before** the window's first anchor pass ever grows
`statList` from its raw DAT-authored height (160px) up to its actual
mounted height. Left alone, the viewport's own `Left|Top|Bottom` anchor
captures its baseline margins lazily on ITS OWN first `ApplyAnchor` call
— which happens AFTER `statList` has already grown — measuring a bogus
non-zero bottom margin that then permanently caps the viewport short on
every later resize (the exact `#372`/`#412`-class bug `UiTemplateListBox
.Viewport`'s own lazy getter already works around). Fixed by calling
`viewport.CaptureCurrentAnchorBaseline()` immediately after
`statList.AddChild(viewport)`, while `viewport.Height` still exactly
equals `statList`'s own current (pre-reflow, zero-margin) height. This
was previously unexercised/untested for Skills (no test asserted its
viewport's exact height against a real window resize) and is now proven
by `CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`.
### No register row
Every number in this section is either a live-probed authored DAT value
or a structural wiring/anchor-capture-correctness fix — nothing here is
inferred or approximated.
**CORRECTED (CT6 fix round, N5, 2026-08-25):** at initial landing, this
sentence was not actually true — the mounted 320/382/1010 clamp WAS an
inference (BLOCKER B1: the host's already chrome-inclusive values with a
second, redundant chrome inset composed on top). After the B1 fix
removes that composition, the mounted clamp is now literally the host's
own four probed values (310/310/372/1000) with zero arithmetic applied,
so the sentence holds for real. The S4 content-height default (362) is
likewise not a new inferred number — it is the same host content-parent
width/height (`0x10000180`, 300×362) this section already probed and
cited above, applied to `Options.ContentHeight` instead of being left
unset.

View file

@ -1,250 +0,0 @@
# Campaign AS ground truth — assess/examination window, PLAYER targets
**AS1 synthesis (2026-08-25).** Three-lens research: our pipeline map, ACE
wire truth (`references/ACE`, cross-checked byte-identical against
`references/Chorizite.ACProtocol`), and the named 2013 retail decomp
(`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This document is
the ORACLE for every Campaign AS slice. Addresses are acclient 2013
v11.4186; property ids verified against ACE enums.
## 0. The headline findings
1. **The 0x00C9 parse is already complete.** `AppraiseInfoParser`
(`src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`) parses every
flag retail parses, in retail's exact section order, INCLUDING
`ArmorLevels = 0x4000` (nine u32s). `Parsed.ArmorLevels` is a dead
field — no production reader. **No wire/parse work is needed.**
2. **Everything missing already arrives from ACE** on a successful player
assess (see §3/§4). The whole campaign is client-side composition.
3. **The core defect is an element MIS-MAPPING** in
`AppraisalUiController.ApplyCreature(character: true)`
(`src/AcDream.App/UI/Layout/AppraisalUiController.cs:673-708`): retail's
`CharExamineUI` ctor `@0x004AD3C0` binds `0x10000150` = Heritage,
`0x10000151` = Profession(title), `0x10000152` = PlayerKiller,
`0x1000053A` = AllegianceName — we feed them raw-HeritageGroup-string /
AllegianceName / MonarchsName / the invented literal
`"Assessment incomplete"` (zero retail provenance; the only occurrence
in the repo).
4. **Zero tests exercise `AppraisalView.Character`** — how the mis-mapping
survived the original Slice 3 gate (that gate checked page selection
only).
5. Retail composes these lines from `u"..."` code literals (not
StringTables), same as the item report — matching retail means literals
here, with DAT/EnumMapper lookups exactly where retail does them
(gender/heritage/title chains, attribute names).
## 1. Retail class map
`gmExaminationUI` (element-type 0x1000001C, `Register @0x004AB780`;
pinnable `gmFloatyExaminationUI` = 0x1000004C). LayoutDesc `0x2100006B`
(DAT-side; already our mount). Sub-UIs implement `ExamineSubUI`
(`acclient.h:54747`): `Init` / `SetAppraiseInfo(profile, isNewObject)` /
`Show`.
| Class | Methods that matter |
|---|---|
| `gmExaminationUI` | ctor `0x004AB2B0`; `PostInit 0x004AD6A0`; `RecvNotice_ExamineObject 0x004AB7B0`; dispatcher `SetAppraiseInfo 0x004ADAE0`; `SetTitleText 0x004AD930`; `UseTime 0x004AB530` (0.75 s combat refresh) |
| `BasicCreatureExamineUI` | ctor `0x004ACD30`; `SetAppraiseInfo 0x004B3F70`; `SetLevelValueText 0x004B3E70`; `AddLineToMiscInfo 0x004ABBB0` |
| `CreatureExamineUI` (monsters) | `SetAppraiseInfo 0x004B3FF0` |
| **`CharExamineUI` (players)** | ctor `0x004AD3C0`; **`SetAppraiseInfo 0x004B45F0`** — the full player line composer |
Sub-UI dispatch (`0x004ADAE0`): creature profile AND (String 5 Template OR
Int 261 CharacterTitleId) → CharExamineUI. Our `SelectView`
(`AppraisalUiController.cs:920-928`) already matches.
Widget ids: title `0x1000012D`; level value `0x1000014C`; attribute list
`0x10000149`; extra list `0x10000335`; row template label `0x1000012A` /
value `0x1000012B`; header texts `0x10000150/0x10000151/0x10000152/0x1000053A`;
viewport `0x10000148`.
## 2. Player line composition (CharExamineUI::SetAppraiseInfo @0x004B45F0)
"colorIdx" = 4th arg of `UIElement_Text::SetTextWithFont @0x0046A500`
the authored alternate-font-color index (attr 0x1B/0x1D list): 0 default,
**1 = buffed/green, 2 = debuffed/red, 3 = unknown/failed** (semantics
pinned by ACE `CreatureProfile.cs:44-45`). Exact authored RGBA lives in
LayoutDesc 0x2100006B (DAT), not the binary.
### 2a. Fixed header elements
| Element | Source | Composition |
|---|---|---|
| Title bar `0x1000012D` | `SetTitleText @0x004AD930`: String 52 override else appropriate object name; stack-count prefix | then the char path OVERWRITES with `AllegianceData::GetFullName @0x005B6950` = `AllegianceSystem::GetTitle(rank=Int 30, heritage=Int 188, gender=Int 113) + " " + name` ("Baroness Aluvia"); plain name when no rank title |
| Heritage `0x10000150` | Int 113 Gender, Int 188 HeritageGroup (Int 2 CreatureType only if heritage==0) | `AppraisalSystem::InqGenderHeritageDisplay @0x005B5AE0``"<Gender> <Heritage>"`. Gender: EnumMapper `0x10000001`; heritage: EnumMapper `0x10000002` with hardcoded overrides `2→"Gharu'ndim"`, `5→"Umbraen"`, `13→"Olthoi"`; creature fallback EnumMapper `0x10000005` (underscore→space) |
| Title `0x10000151` | Int 261 CharacterTitleId, fallback String 5 Template | `CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0`: EnumMapper `0x10000006``compute_str_hash` → StringTable behind table-enum `0x10000007`. Current display title only. Same pipeline as `CharacterTitleResolver` (CT campaign). |
| PK `0x10000152` | **NOT from the appraisal payload** — local `cur_weenobj` PWD bits: `IsPK() @0x0058C8B0``u"Player Killer"`, `IsPKLite() @0x0058C8A0``u"Player Killer Lite"`, else `u"Non-Player Killer"`. Skipped (left cleared) if the weenie is gone. Ours: `ClientObject.PublicWeenieBitfield` + `PlayerKillerStatusBitfield` (bits 0x20 / 0x02000000) — the exact port already exists (`ClientObject.cs:439-451`, per AP-109's CT4 correction). |
| Allegiance `0x1000053A` | String 47 AllegianceName | plain set, element cleared first; only inside the `Int 30 AllegianceRank >= 1` gate |
| Level `0x1000014C` | Int 25 | `SetLevelValueText @0x004B3E70`: >0 → comma-grouped; ≤0/absent → `"???"` |
### 2b. Extra-info list rows (list `0x10000335`), EXACT ORDER
Every row = `AddLineToMiscInfo(label, value, colorIdx) @0x004ABBB0`.
Literals: `"%d"` @0x794344/0x7A0184; `"*%d"` @0x7B110C; `u"???"` @0x7B0F34;
`L""` spacer @0x794320.
| # | Gate | Label | Value | Color |
|---|---|---|---|---|
| 1 | **PRESENCE of Int 281** Faction1Bits — `AppraisalProfile::InqInt @0x005B3830` returns found/not-found, NOT value≠0 (AS4-review adjudicated); present-and-zero → `Society: ???` | `Society:` | society name (+` ~ <rank>` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, then the `???` arm, THEN bit4→"Radiant Blood"+Int 289 (retail's odd test order); unrecognized→`???`. Bands: 1100 Initiate, 101300 Adept, 301600 Knight, 6011000 Lord, 10011500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits, SAME-BIT-FIRST (`@0x004b49fd/@0x004b4a49/@0x004b4a8b`): local carries the target's bit → 1 (green) even when local also carries other bits; else local carries any other society bit → 2 (red); else 0 |
| 2 | Int 30 ≥ 1 | allegiance rows | Str 21 MonarchsTitle absent → `Alleg. Monarch:` + `%d Follower`/`%d Followers` (Int 35, clamp ≥0). Str 21 present, Str 35 PatronsTitle absent → `Monarch:`+Str21. Both present, equal → one row `Monarch/Patron:`. Different → two rows `Monarch:` / `Patron:` | 0 |
| 3 | any of 9 AL > 0 | spacer, then `Head/Chest/Groin`, `Bicep/Wrist/Hand`, `Thigh/Shin/Foot` | per part `"%d"`, or `"*%d"` with (value9999) when ≥9999 (unenchantable sentinel); value cell = `"AL: %s/%s/%s"`. Nine dwords in order head, chest, groin(=Abdomen), bicep(=UpperArm), wrist(=LowerArm), hand, thigh(=UpperLeg), shin(=LowerLeg), foot | 0 |
| 4 | Int 307\|313\|314 > 0 | spacer, then `Dmg/CritDmg` | `Rating: %d/%d` ← (307 DamageRating, 314 CritDamageRating); 313 gates only | 0 |
| 5 | Int 308\|315\|316 > 0 | [spacer if row 4 absent] `Dmg/CritDmg` | `Resist: %d/%d` ← (308, 316); 315 gates only | 0 |
| 6 | Int 350\|351 > 0 | [spacer if none yet] `DoT/Life:` | `Resist: %d/%d` ← (350, 351) | 0 |
| 7 | any of rows 46 shown | trailing spacer | | |
| 8 | Str 10 Fellowship | `Fellowship:` | verbatim | 0 |
| 9 | Str 43 DateOfBirth | `Arrived in Dereth:` | verbatim (server-formatted) | 0 |
| 10 | Int 125 Age | `Time in Dereth:` | `ClientUISystem::DeltaTimeToString(seconds)` | 0 |
| 11 | Int 181 ChessRank | `Chess Rank:` | `%d` | 0 |
| 12 | Int 192 FakeFishingSkill | `Fishing Skill:` | `%d` | 0 |
| 13 | Int 43 NumDeaths | `Deaths:` | ≤0 → `Has never died`, else `%d` | 0 |
| 14 | Int 262 NumCharacterTitles | `Titles Earned:` | `%d` | 0 |
| 15 | unconditional (per decomp — see R3) | `* = Unenchantable` in the LABEL slot, empty value | legend | 0 |
Then the tail calls `BasicCreatureExamineUI::SetAppraiseInfo @0x004B3F70`
(level + attribute token update).
Note: retail's ratings format strings are `"%Rating: %d/%d"` /
`"%Resist: %d/%d"` — the leading `%R` is invalid printf that msvcrt renders
as literal `R…`, so the display text is `Rating: x/y` / `Resist: x/y`. Our
existing `CreatureAppraisalRows.BuildExtra` output matches the display.
### 2c. Attribute/vital rows (list `0x10000149`)
Created once in ctor order **Str, End, Coord, Quick, Focus, Self** then
**Health (percent shown), Stamina, Mana**; labels via
`SkillSystem::InqAttributeName @0x005C8D90` (StringTable-backed).
`AttributeInfoRegion::Update @0x004F1D90`: `"%d"`, 0 → `"???"`; color:
success==0 → 3, else enchant HI bit → 1 (green) / LO-without-HI → 2 (red)
(`InqAttributeEnchantmentMod @0x005B5EC0`).
`Attribute2ndInfoRegion::Update @0x004F1E80`: `%d/%d`, Health
`%d/%d (%d %%)`; **success==0: Health shows `%d %%` ONLY; Stamina/Mana show
`???`**; same color rule on the MAX-vital bits.
## 3. ACE wire truth (what the local server sends for a player)
Serialization: flags dword, Success dword, then sections in retail's parse
order (Int 0x1 → Int64 0x2000 → Bool 0x2 → Float 0x4 → String 0x8 → DID
0x1000 → SpellBook 0x10 → ArmorProfile 0x80 → CreatureProfile 0x100 →
WeaponProfile 0x20 → HookProfile 0x40 → ArmorEnch 0x200 → WeaponEnch 0x800
→ ResistEnch 0x400 → ArmorLevels 0x4000). Byte-identical vs Chorizite.
Player targets set: Int (always), String (usually), DID (appearance dids),
CreatureProfile (always, even failed), ArmorLevels (Success only).
**ArmorLevels** (`ACE .../Structure/ArmorLevel.cs:85-96`): nine u32
Head, Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg, LowerLeg, Foot.
Sent when `Success && (Player || !Attackable)`. Values are **BUFFED**
(base + enchant mod per covering Clothing layer, clamped ≥0); if EVERY
covering layer is unenchantable the part gets **+9999** (the `*` sentinel).
Chorizite's "BaseArmor*" naming is misleading — trust ACE.
**Identity ints:** Gender **113**, HeritageGroup **188**,
PlayerKillerStatus **134** (ACE enum NPK 0x02 / PK 0x04 / PKLite 0x40 — NOT
the PWD bit layout; retail reads the local weenie's PWD bits instead, and so
do we), CharacterTitleId **261**, NumCharacterTitles **262**, Level **25**.
The display strings never ride the wire — client-composed.
**Option-gated extras** (removed from the tables when the TARGET's option
is off; `AppraiseInfo.cs:352-366`): DateOfBirth str **43**
(AllowOthersToSeeYourDateOfBirth), Age int **125**, ChessRank int **181**,
FakeFishingSkill int **192**, NumDeaths int **43**, NumCharacterTitles int
**262**. Client renders what's present — no client-side option logic.
**Ratings** (Success only, nonzero only, straight into IntStats):
Damage **307** (+5 heritage-weapon bonus applied server-side),
DamageResist **308**, Crit **313**, CritDamage **314**, CritResist **315**,
CritDamageResist **316**, HealingBoost **323**, NetherResist **331**,
DotResist **350**, LifeResist **351**, GearMaxHealth **379**, PKDamage
**381**, PKDamageResist **382**. Retail renders only
307/314 · 308/316 · 350/351 (313/315 gate; 323 read but never rendered).
**Allegiance/faction:** AllegianceName str **47**; monarch targets get
AllegianceFollowers int **35**; non-monarch get MonarchsTitle str **21**
(server-composed "<rank title> <name>") + PatronsTitle str **35**;
AllegianceRank int **30**; Faction1Bits int **281** (masked to 0x7);
Society ranks ints **287/288/289**; Fellowship str **10**.
**Failure semantics:** failed assess (target's Deception beats examiner's
AssessPerson, target opted into AttemptToDeceiveOtherPlayers) → Success=0
but ACE STILL sends full int/string/DID tables; only CreatureProfile
attributes (ShowAttributes flag 0x8), ratings, and ArmorLevels are gated.
Target sees "X tried and failed to assess you!"; retries inside 5 s
auto-fail. Guid-not-found → `Flags=0, Success=0` only.
## 4. Current acdream state (what exists, what's wrong)
- Window: `AppraisalUiController` (LayoutDesc 0x2100006B, root 0x100005F2),
mounted as independent top-level floaty; subviews Item/Creature/
Character/Spell; `UiText` + `UiItemList`/`UiTemplateListSlot` rows from
DAT template `0x10000166`; `CreatureAppraisalLayeredList` two-list
chrome/foreground split over one `UiScrollable`; animated clone in
`UiViewport 0x10000148` (`CreatureAppraisalPresentation`).
- Route: `GameEventWiring.cs:1003``LiveSessionEventRouter.OnAppraisal`
`RetailUiRuntime.HandleAppraisal``Apply(Parsed)`.
- Stale-response rejection, 0.75 s visible-combat refresh, busy-cursor
balance: present and pinned.
- Ratings rows exist (`CreatureAppraisalRows.BuildExtra`,
`CreatureAppraisalRows.cs:97-146`) with the retail grouping pinned by
`ExtraRatingsFollowRetailGroupingFormattingAndSeparators`. BuildExtra
takes only a `PropertyBundle` — it cannot see `Parsed.ArmorLevels`
(signature change needed; armor rows precede rating rows).
- Attribute rows: nine ordered rows + `???` failure semantics + enchant
bit styles pinned in `CreatureAppraisalRowsTests`.
- Reusable CT ports: `CharacterIdentityText` (gender/heritage composition,
`CharacterIdentityText.cs:81-117`), `CharacterTitleResolver` (EnumMapper
0x22000041→hash→StringTable 0x2300000E — the SAME retail pipeline; the
decomp's 0x10000006/0x10000007 are element-scope enum ids resolved to
those DAT ids), `PlayerKillerStatusBitfield`.
### Gap ledger
| # | Gap | Fix slice |
|---|---|---|
| G1 | Heritage slot fed raw string prop instead of composed gender+heritage | AS2 |
| G2 | Title slot fed AllegianceName; title never displayed | AS2 |
| G3 | PK slot fed MonarchsName; PK never displayed | AS2 |
| G4 | Armor-level trio absent (parsed, never read) | AS3 |
| G5 | `0x1000053A` shows invented "Assessment incomplete" instead of AllegianceName | AS2 |
| G6 | Society/faction row absent | AS4 |
| G7 | Monarch/Patron/Followers rows absent | AS4 |
| G8 | Configurable extras absent (Fellowship/DOB/Age/Chess/Fishing/Deaths/Titles + legend) | AS3 (legend) + AS4 |
| G9 | Title-bar allegiance rank prefix absent (AP-109's 17-function table) | AS5 |
| G10 | Zero Character-subview test coverage | every slice adds |
## 5. AS1 rulings on the open questions
- **R1 (String 52 override):** keep current `BuildTitle` behavior — String
52 is absent for players; harmless either way.
- **R2 (Int 323 HealingBoost):** retail reads it and never renders it; we
keep discarding it. Do not add a row.
- **R3 (`* = Unenchantable` legend unconditional):** SETTLED AT SOURCE at
the AS3 review — the legend at `004b5d7d-004b5dcd` sits OUTSIDE the
`if (InqCreature(...))` block (opened `004b4638`, closed at pseudo-C line
189962), so the BN-flattening theory is dead: retail adds it
unconditionally, into the same `m_extraInfoList` (0x10000335), text in
the LABEL slot. Retail's outside-the-gate placement is unobservable in
practice (the dispatcher only routes to CharExamineUI when a creature
profile exists), so "always, on the character path" is the faithful
port. The connected gate keeps its side-by-side check for the VISUAL
question only; do not re-investigate the structure.
- **R4 (ratings spacer discipline):** the BN output lost the flag
assignments in CharExamineUI; use `CreatureExamineUI::SetAppraiseInfo
@0x004B3FF0`'s clean version of the SAME logic: one spacer before the
first ratings row, one trailing spacer if any rating row was emitted.
- **R5 (colorIdx RGBA):** authored in LayoutDesc 0x2100006B text attrs
(0x1B/0x1D). CORRECTED at the AS4 review: the row model CARRIES the
style (`CreatureAppraisalValueStyle`), but `ResolveColor` is a
deliberate no-op — every row renders the authored default color until
AP-110's "creature FontInfo-list selection" residual lands. Compute the
semantic state, never hardcode RGBA; the Society green/red is therefore
MODEL-ONLY today and INVISIBLE at the connected gate (the AS6 script
must not gate on it).
- **R6 (literals):** the header/extras labels are code literals in retail —
match them as literals (consistent with the item report), with DAT
lookups only where retail does them (gender/heritage EnumMappers, title
chain, attribute names).
- **R7 (PK source):** from the assessed `ClientObject`'s PWD bits via
`PlayerKillerStatusBitfield`, NOT PropertyInt 134 (AP-109's CT4 lesson);
leave the element cleared when the object is no longer in the table.
- **R8 (rank source for GetFullName):** `props.GetInt(0x1E)` — the
appraisal bundle carries AllegianceRank; never read
`RuntimeAllegianceState` for another player's rank.

View file

@ -1,147 +0,0 @@
# Campaign AS — connected gate script (AS6, user-driven)
**Purpose:** live verification of the assess/examination-window parity
campaign (AS2AS5) against ACE, retail side-by-side as the oracle.
Launch: the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at
`127.0.0.1:9000`, `ACDREAM_PAK_PATH=<worktree>\artifacts\owner-gate\acdream-v5.pak`).
Sections marked **[TWO-CLIENT]** use the FA-campaign account pair
(`testaccount`/`+Acdream` + `testaccount2`/`+Horan`) — assess the OTHER
player. Where a second retail client is available, compare the same assess
performed from retail.
**Two standing rulings, read before judging:**
- **Row COLORS are out of scope this gate.** The Society green/red (and any
buffed/debuffed row tint) is MODEL-ONLY today — `ResolveColor` renders
the authored default until AP-110's FontInfo-list residual lands. Judge
TEXT content, ordering, and presence only.
- **The paperdoll is our registered intentional deviation (AD-114):** ours
mirrors the target's live motion; retail's clone plays its own decoupled
idle cycle. A mismatch there is EXPECTED and correct.
---
## 1. Header identity block (AS2) [TWO-CLIENT]
Assess the other player. Under the title bar, four lines:
1. **Gender+heritage**: "<Gender> <Heritage>" composed (e.g. "Female
Aluvian") — not a raw property string. PASS: matches retail's line for
the same target.
2. **Title**: the target's CURRENT display title (e.g. "War Mage"). Have
the target change their display title (Character panel → Titles → Set
as Display Title), re-assess: the new title shows. No title set →
the line is empty (ours deliberately clears where retail can show a
stale previous target's title — register AD-115; do not fail us for
being cleaner than retail here).
3. **PK status**: "Non-Player Killer" for a normal character. (If a PK or
PKLite character is available, verify its variant.)
4. **Allegiance name**: shown only when the target is in a named
allegiance (sworn, rank ≥ 1); otherwise empty.
5. **Failed assess**: on the TARGET client enable Deception's
"Attempt to Deceive" option (retail Options → Character) if available
with a high-Deception target; a failed assess shows "???" attributes
BUT the four header lines above still render (identity rides even on
failure). Best-effort — needs a target whose Deception beats the
examiner's Assess Person.
6. **The invented literal is gone**: assess a MONSTER and fail (or any
monster assess) — the line that used to read "Assessment incomplete"
shows nothing, matching retail.
## 2. Per-bodypart armor levels (AS3) [TWO-CLIENT]
1. Target wearing armor: three rows appear —
"Head/Chest/Groin AL: a/b/c", "Bicep/Wrist/Hand AL: a/b/c",
"Thigh/Shin/Foot AL: a/b/c" — values matching retail's assess of the
same target (buffed values: cast an armor buff on the target and
re-assess; numbers rise).
2. Target naked (bank the armor): the three rows AND their leading blank
line disappear entirely.
3. Unenchantable coverage (if an unenchantable piece is available): the
affected group shows "*N" (N = the level without the sentinel).
4. **The R3 legend check (explicit retail side-by-side):** "* =
Unenchantable" renders as the LAST line of a player assess. Our port
shows it UNCONDITIONALLY (decomp-proven structure). Confirm retail
does the same on a player with NO starred values and again with NO
armor at all. If retail hides it in either case, report it — that
flips ruling R3 and we change ours.
## 3. Ratings block (regression)
On a target with ratings (augmented/geared): "Dmg/CritDmg Rating: x/y",
"Dmg/CritDmg Resist: x/y", "DoT/Life: Resist: x/y" rows as before,
each appearing only when its family is nonzero. Unchanged behavior —
spot-check only.
## 4. Society / allegiance / fellowship rows (AS4) [TWO-CLIENT]
1. **Fellowship**: form a fellowship (FA campaign flow), assess the other
member: "Fellowship: <name>" row appears; disband → row gone on
re-assess.
2. **Allegiance cascade** (best-effort — ACE's swear-at-close-range quirk
#384 may block creating fresh vassals): assess a sworn character —
"Monarch:" (and "Patron:" when different, "Monarch/Patron:" when the
same person). Assess a MONARCH: "Alleg. Monarch:" + "N Follower(s)".
3. **Society** (only if a faction-joined character exists on this ACE):
"Society: <name>" with the rank suffix (" ~ Initiate" … " ~ Master")
per the target's standing. TEXT only — ignore colors (standing ruling).
## 5. Target-configurable extras (AS4) [TWO-CLIENT]
On the TARGET client open Options → the retail Character tab and toggle
each "Allow others to see..." option, re-assessing from the other client
after each change (each row appears iff the target allows it — the server
gates; we render what arrives):
| Toggle on target | Row | Expected value shape |
|---|---|---|
| Date of Birth | `Arrived in Dereth:` | server-formatted date, verbatim |
| Age | `Time in Dereth:` | retail duration format — bare units, e.g. "3mo 2d 5h 12m 40s"; zero components omitted except seconds |
| Chess Rank | `Chess Rank:` | number |
| Fishing Skill | `Fishing Skill:` | number |
| Number of Deaths | `Deaths:` | number; a deathless character shows "Has never died" as the value (label stays "Deaths:") |
| Number of Titles | `Titles Earned:` | number |
PASS: each row toggles with its option, the labels match retail exactly,
and the full extras ORDER matches retail: Society → allegiance rows →
[blank] → armor trio → [blank] → ratings → [blank] → Fellowship → Arrived
→ Time → Chess → Fishing → Deaths → Titles → "* = Unenchantable".
## 6. Title-bar allegiance rank prefix (AS5) [TWO-CLIENT, best-effort]
Assess a sworn character with an allegiance rank: the WINDOW TITLE reads
"<RankTitle> <Name>" (e.g. "Yeoman Horan") — heritage- and
gender-specific title from retail's table. The character panel (F9) name
line of YOUR OWN sworn character shows the same prefix. Unsworn/rank 0 →
plain name in both places. (Creating a fresh sworn pair may be blocked by
#384 — use any already-sworn character; otherwise mark SKIPPED.)
## 7. Combat auto-refresh
Enter combat mode with the exam window open on a player: the window
refreshes ~every 0.75 s; the AL trio, extras, and header lines persist
and update (buff the target's armor mid-watch: values change without
re-assessing manually).
## 8. Regression sweep (5 minutes)
- Monster assess: species line + stat rows + ratings as before; NO armor
trio, NO legend, nothing where the old invented literal was.
- Item assess: the full item report unchanged; inscription flow intact.
- Spell assess unchanged.
- The animated paperdoll mirrors the target's motion (our AD-114
deviation — expected).
- Character panel (F9): CT-campaign behaviors intact (titles page, header
identity block, resize clamps 3721000, scrollbar hand-off).
---
## Report back
Per section PASS/FAIL plus anything odd. The three answers that matter
most:
1. §5 — does every option-gated row toggle correctly with the target's
own options, in retail's exact order?
2. §2.4 — does retail show the "* = Unenchantable" legend unconditionally
(our R3 reading), or does it hide it in some case?
3. §1 — is the identity block exact against retail for the same target
(composition, title live-update, PK text)?

View file

@ -1,179 +0,0 @@
# Campaign CT — connected gate script (CT7, user-driven)
**Purpose:** live verification of the character-panel parity campaign
(CT1CT6) against ACE. Launch: the normal connected launch
(`ACDREAM_RETAIL_UI=1`, live ACE at `127.0.0.1:9000`,
`ACDREAM_PAK_PATH=<worktree>\artifacts\owner-gate\acdream-v5.pak`).
Open the character panel (F9 / toolbar). Retail side-by-side comparison
is the oracle for every visual item.
Useful ACE console helpers: title grants come from quests/admin — check
`@acecommands` for a title-grant command; `@grantxp` for levels.
---
## 1. Header identity block (CT4) — Attributes AND Skills tabs
1. **Name line**: the plain character name (rankless characters — the
allegiance rank prefix is a registered deferral, AP-109).
2. **Heritage line**: "<Gender> <Heritage> <DisplayTitle>" — e.g.
"Female Aluvian War Mage" when a display title is set; just
"Female Aluvian" when none. PASS: matches retail's composition and
spacing exactly; a title beginning with "The" shows unmangled.
3. **PK line**: "Non-Player Killer" (or "Player Killer" / "Player
Killer Lite" on a PK/PKL character) in PURE WHITE. PASS: correct
text + color on BOTH the Attributes and Skills tabs.
4. **Level number**: pale gold with outline (authored color — compare
against retail's level display side-by-side; the owner reported ours
was previously off).
5. **Luminance pair**: on a sub-200 character, NO luminance caption or
value renders. (A level ≥ 200 character with MaximumLuminance shows
"Luminance:" and "<available> / <maximum>" — verify only if such a
character is available.)
6. **Live update**: set a display title (see §2) — the heritage line
updates the moment the server confirms, with no relog and no panel
re-open.
## 2. Titles tab (CT3)
1. Click the **Titles** tab. PASS: the page shows "Current Display
Title:" + the current title (or "Unknown" only when the server's
title id fails to resolve — normally a real title or the authored
empty state), the "All Available Titles:" list, and the
"Set as Display Title" button.
2. **List content**: every earned title, alphabetically sorted,
readable rows. With few titles the scrollbar shows retail's
full-track thumb; with many (if available) the thumb sizes
proportionally and scrolls.
3. **Ghost rule**: with NOTHING selected the button is ghosted. Select
the title that IS the current display title — button stays ghosted.
Select a DIFFERENT title — button un-ghosts.
4. **Set round trip**: click Set as Display Title. PASS: the display
title text updates on the server's confirmation, the selection
CLEARS (row highlight goes dark — retail behavior), the button
re-ghosts, and the §1 heritage line updates live.
5. **Row selection visual**: the selected row highlights with retail's
row highlight art (full-row background swap), not a synthesized bar.
## 3. Attribute/skill rows (CT5)
1. **Icon alignment**: row icons sit flush left (20x20 at the row's
left edge), matching retail — the previous inset/smaller icons are
gone. Compare a few rows side-by-side against retail.
2. **Value gutter**: the numbers column ends with a visible margin
before the panel border (the scrollbar band) — retail's 7px gutter.
3. **Row height**: rows are retail-height (slightly tighter than
before); section headers (Trained/Untrained/Unusable) unchanged.
4. **Selection highlight**: clicking a row highlights with the retail
full-row art; the spellbook's selection visuals are UNCHANGED
(regression check — open the spellbook and select a spell).
5. **Raise buttons / tooltips / footer**: regression sweep — raise ×1
and ×10 still work with correct ghosting, skill tooltips still show
formula + description wrapped correctly, the footer numbers update.
## 4. Resize + scrollbar (CT6)
Ground truth (2026-08-25 live probe against layout `0x2100006E`, host
`0x100005FE``docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`
§CT6, corrected by the CT6 fix round's BLOCKER B1): the resize clamp is
authored on the SHARED `gmPanelUI` host, not the character content
itself, and the host IS retail's own outer window frame — its authored
values are chrome-INCLUSIVE, not a content size our own chrome adds on
top of. Host authors **MinWidth=MaxWidth=310** (fixed width — no
horizontal Resizebar) and **MinHeight=372, MaxHeight=1000**. The
MOUNTED window's outer bounds are EXACTLY those same numbers: width
fixed **310px**, floor **372px**, ceiling **1000px** — no inset is
added on top (`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame`
now tells the mount path this source already includes the bevel).
**Starting height:** the window OPENS at retail's authored default,
**372px** — its own resize floor. It cannot open any shorter; it can
only be dragged taller.
1. **Grab the bottom edge and drag up (shrink).** PASS: the window
stops shrinking at its authored floor (372px outer / the point where
further dragging has no visible effect) — since the window already
OPENS at that floor, this step should show no shrink at all (there
is no room below the default to shrink into). It does NOT collapse
arbitrarily small. Retail comparison: drag retail's own Character/
Skills window down from its own default; it should likewise refuse
to shrink further immediately.
2. **Keep dragging down (grow).** PASS: the window keeps growing until
its authored ceiling (1000px outer) — same side-by-side comparison
against retail's own ceiling.
3. **Left/right edges do not resize.** Only the bottom edge (and top
Dragbar for moving, not resizing) responds — matches retail's
fixed-width authoring (no horizontal Resizebar).
4. **Scrollbar hand-off, Attributes tab.** At the default window size
(372px) the 9 attribute/vital rows (180px content) OVERFLOW the
160px list — the scrollbar is ACTIVE (visible + interactive)
IMMEDIATELY on open, not after shrinking. PASS: this is the owner's
item 2 fix — previously the scrollbar never appeared on Attributes
at all. Grow the window until the rows fit without scrolling: the
bar DISAPPEARS entirely (0x1000023E authors 0x79 hide-when-disabled
TRUE — a fitting list hides the bar, it does not leave a full-track
"disabled" thumb visible). Shrink back down and the bar reappears.
5. **Scrollbar hand-off, Skills tab.** Same immediate-overflow-at-
default check (a longer skills list only makes the overflow more
obvious); grow until it fits and confirm the bar disappears the same
way.
6. **Scrollbar hand-off, Titles tab.** With several earned titles, the
Titles list (authored 455px, inside the 575px page) is scroll-clipped
into the same 372px-default window and its own scrollbar
(`0x10000533`, also hide-when-disabled — fixture-verified) takes over
the same way: active when titles overflow, hidden when the window is
grown enough that they all fit.
7. **Footer stays bottom-docked.** While shrinking/growing on the
Attributes/Skills tabs, the footer (raise buttons / selected-stat
info) stays pinned to the bottom edge — it does not float mid-window
or get clipped early.
8. **Grow back restores.** Drag back down to the original default
(372px): the lists return to their default OVERFLOWING state
(scrollbar reactivates — this is the default, not "all rows fit")
and the window returns to its original proportions.
9. Other windows (chat, social) still clamp at their own authored
minimums — regression check (chat: min 300×100, max 2000×2000 per
`CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`).
## 5. Regression sweep (5 minutes)
- Attributes/Skills tab switching unaffected; CA5 behaviors intact
(raise round trips, live run-speed update on Quickness).
- Logout/login: titles and display title persist; the header matches
PlayerDescription's values.
- Chat window: the CH-round fixes hold (input rails on focus, "Gen"
caption, button flick, no vibrating text while dragging).
- **CT-GF1 fix round (client-wide ancestor clip) — eyeball items.** The new
default clips every element to its own box by default; these are the
windows most likely to show a silent over-clip (content trimmed that
should be visible) if the port has an edge case the automated suite
didn't catch:
- **Collapsed toolbar**: collapse the combat/spell toolbar to its narrow
strip and back — confirm nothing inside it (icons, the collapse grip)
gets cut off or fails to reappear on expand.
- **Combat/vitals bar**: at its default size, confirm the health/
stamina/mana bars and their numeric overlays render in full, not
trimmed at an edge.
- **Options panel bottom-button row** (Gameplay tab): confirm all seven
buttons (Exit to Character Selection, Configure Keyboard, In-Game
Help, Urgent Assistance, Report Abuse, mouse-turning checkbox, Exit
Game) render completely, none clipped at the panel's bottom edge.
- **Map/house page**: confirm the map image and player/house icons
render in full across the page's own scroll/zoom range, not clipped
at the viewport edge.
- **Floaty chat** (a detached floating chat window, Alt+1..4): confirm
the transcript and input row render in full at both a small and a
resized-larger window size — the same class of symptom CT-GF1's own
`ChatLayoutConformanceTests` regression-pinned for the main chat
window's input row.
---
## Report back
Per section: PASS/FAIL plus anything odd. The three answers that matter
most:
1. §2.4 — does the set-title round trip clear the selection and update
the header live?
2. §3.1/§3.2 — do icons and the value gutter now match retail
side-by-side?
3. §1.3 — is the PK line present, white, and correct on both tabs?

View file

@ -1,131 +0,0 @@
# Combined client parity owner gate
**Date prepared:** 2026-08-26
**Run:** OWNER-COMPLETE 2026-08-26 on one exact Release binary.
**Scope:** #443#450 plus the complete inventory/vendor interaction audit.
**Outcome:** Sections AC and EF passed, including the mid-drag cursor-icon
re-test added during the round. Issues #444#450 are owner-accepted. Section D
failed: the private paperdoll remained missing, so #443 stays open as the only
surviving defect. Tested executable SHA-256:
`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`.
Keep the client log for the whole run and take a screenshot for any visual or
text mismatch. For every refused item action, record the cursor color, exact
SpewBox line, and whether the item visibly moved before the refusal.
## A. Keyboard, camera, combat input, and relog — #446/#450
1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle
Walk/Run already owns it. Verify the retail conflict dialog appears and the
chosen resolution is honored. Repeat with a known allowed shared chord.
2. Apply a changed binding, close/reopen Options, then restart the client.
Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`.
3. Press regular Enter: chat input must focus. Press keypad Enter outside chat:
it must perform only its configured camera action and must not focus chat.
4. Press bare Escape through the retail ladder: cancel target/focus/selection
first, then toggle Gameplay Options. It must never enter a developer orbit
or bird's-eye mode. Preserve the approved mouse-wheel zoom range.
5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the
key is held and attack only on release, using the selected height.
6. Shift+Escape to character selection, immediately re-enter, and verify the
destination loads and exits portal space. Repeat twice in one process.
## B. Chat and combat text — #447/#448
1. Run `@acecommands`. Every non-empty server line must be visible; an
oversized response must retain the newest complete lines, not blank the
transcript.
2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal
text, filtering, and scrolling remain intact.
3. Land ordinary and critical melee hits. Outgoing lines must match retail
wording and punctuation and contain no acdream-added percentage.
## C. Selection, use, containers, movement, and splitting — #445/#449
Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a
Pyreal stack, one side pack, a full main pack, a full side pack, an open
external container, and a creature/player target.
1. Single-click and right-click inventory, side-pack, external-container, and
paperdoll items. Verify one global selection, stable highlight, status text,
and right-click examination.
2. Select an owned Pyreal stack. The toolbar must read
`<stack> <appropriate name> (of <total carried Pyreals>)`, with no comma
insertion added by acdream.
3. Single- and double-click a carried side pack. It must open on press, issue
no generic item-use request, and remain stably selected.
4. Double-click usable, unusable, wearable, and wieldable items. Verify one
action. Any local refusal must appear once in SpewBox, not in normal chat.
5. Move a full item between main pack and side pack. Before acknowledgement,
the source must remain canonical with retail's waiting/ghost presentation;
after success it appears only at the destination. Force one rejection and
verify no duplicate, disappearance, or speculative capacity change.
While holding the item under the cursor, move across the inventory and wait
through several ordinary object updates: the cursor icon must remain visible
until release. This re-gates the mid-drag procedural-refresh fix found during
the first 2026-08-26 owner pass.
6. Fill the main pack, observe a rejected move, drop one loose item, then move
an item from a side pack into the freed slot immediately. It must accept
without reopening the inventory window (#449).
7. From a stack of 10, select 2 and split into an empty main-pack slot, an open
side pack, and an external container. Each successful result must be 8+2.
8. Merge full and partial stacks. Verify selected quantity, target selection,
target-cap clamp, source remainder, and exact refusal text for a full target.
9. Drop full and partial stacks to the world, then pick them up. Verify pending
visuals, authoritative commit, failure cleanup, and no duplicate object.
10. Give a full and partial stack to a creature; drag onto another player and
verify secure-trade routing. Hover rejection must stay silent; release
rejection must print the exact ClientLocal reason.
11. Equip once by double-click and once by paperdoll drag. Test a clothing
conflict and weapon replacement. Canonical inventory/paperdoll ownership
must not change before the authoritative response.
## D. Private paperdoll viewport — #443
1. From a fresh process, open inventory and assess one monster and one player.
2. The animated doll must be present on first open, not appear only after a
delay. Close/reopen each view several times and change equipment once.
3. Record #443 independently if the viewport is late or missing even when the
underlying equip/inventory transaction is correct.
## E. Vendor parity and alternate currency — #444/#445
1. On the browse list, single-click selects, right-click examines, and
double-click buys exactly one/current-slider unit through the normal retail
purchase path.
2. Add a stack quantity greater than one to Buying. Double-click its staged
row: remove exactly one unit and print
`Removing <name> from shopping list` once in SpewBox.
3. Stage an owned item in Selling. Right-click examines it; double-click
removes the whole staged entry and prints the same removal form.
4. Drag a staged Selling row: it must unstage. Repeat after selecting only part
of its stack: SpewBox must print
`You cannot split items from this panel` and the slider must reset to max.
5. From an owned stack of 10, select 2 and drag into Selling. Verify the
temporary row resolves to the new authoritative stack of 2, Sell All sells
exactly 2, and 8 remain (#445).
6. At an alternate-currency vendor, note holdings in the Items cost sentence
and Buying/Selling purse lines. Buy once: every visible holding must decrease
immediately and remain correct after authoritative inventory refresh,
tab changes, and vendor reopen (#444).
7. Buy All once with enough currency and once without enough. The first uses
the refreshed balance; the second prints retail's insufficient-funds notice
and sends no purchase.
## F. Re-entrant and lifecycle stress
1. Change selection during a pending split, close an external container during
a pending move, and retry immediately after a refusal.
2. Attempt a second inventory operation while one request is pending. Verify a
clean retail refusal/no-op, never duplicated wire action or stuck busy state.
3. Log out or portal with a recently completed interaction, re-enter, and
verify pending projections and the request ledger converge to zero.
## Pass rule
The pass rule was satisfied for #444#450 on the exact binary recorded above.
#443 remains open by itself because the transaction rows passed and the failure
was confined to private viewport residency.

View file

@ -1,43 +0,0 @@
# Issues #444, #445, #447 — consolidated owner gate
Run these checks together on the next connected test build. They deliberately
require no special probes; record the client log and one screenshot per
section. If a split fails, also record the exact visible error text.
## #444 — alternate-currency vendor balance
1. Open a vendor that accepts an alternate currency and note the amount shown
in both the Items cost sentence and the Buying-tab purse line.
2. Buy one item.
3. Confirm both visible amounts decrease immediately and remain correct after
the server refresh. Close/reopen the tab and vendor and confirm the amount
does not bounce back to the old snapshot.
4. Buy All once with enough currency, then once without enough. Confirm the
first uses the refreshed holding and the second shows retail's insufficient-
money notice without sending a purchase.
## #445 — inventory and vendor partial stack splits
1. In the main pack, select a stack of 10, set the slider to 2, and drag it to
an empty main-pack slot while at least one side bag is equipped.
2. Confirm the source becomes 8 and a new stack of exactly 2 appears at the
chosen loose-item position; no error should appear.
3. Repeat into an open side bag and confirm the same 8+2 result.
4. Reset to a stack of 10, select 2, and drag it onto the vendor Selling list.
Confirm the client prints `Splitting the <name> before selling them`, then
the staged row resolves to the new stack of 2 rather than the source stack.
5. Press Sell All and confirm exactly 2 are sold and 8 remain.
## #447`@acecommands` multiline response
1. Run `@acecommands` on the test account.
2. Confirm command text is visible, consecutive server lines are readable,
and there is no screen of blank chat rows.
3. Scroll through the retained result. A response beyond retail's 10,000-
character transcript cap should retain the newest complete command lines
instead of blanking the entire response.
4. Run `@acehelp acecommands` and one ordinary chat command afterward; confirm
their text and normal chat presentation remain intact.
Pass all three sections on one exact binary, then mark #444/#445/#447
owner-accepted together.

View file

@ -1,447 +0,0 @@
# MossTank research: Virindi Tank parity and UtilityBelt expressions
Date: 2026-08-26
This report is the requirements baseline for turning MossTank from the small
self-buffing sample into acdream's full automation plugin. The product target is
deliberately broad: **all Virindi Tank functionality**, with UtilityBelt's more
capable expression dialect as the scripting baseline. File compatibility is a
separate decision; behavioral capability is not.
## 1. Evidence and limits
The following primary VTank pages were read and cross-checked (the live wiki
and its indexed historical revisions were both used where a mirror was
temporarily unavailable):
- `http://virindi.net/wiki/index.php/Virindi_Tank`
- `http://virindi.net/wiki/index.php/Virindi_Tank_Standard_Options`
- `http://virindi.net/wiki/index.php/Virindi_Tank_Advanced_Options`
- `http://virindi.net/wiki/index.php/Virindi_Tank_Commands`
- `http://virindi.net/wiki/index.php/Virindi_Tank_Meta_System`
- `http://virindi.net/wiki/index.php/Meta_Expressions`
- `https://utilitybelt.gitlab.io/docs/expressions/`
The 2026-08-27 MT2 follow-up also verified the exact documented distinctions
that drive the combat scheduler:
- A+R requires `MinimumRingTargets` inside Ring Range; R without A rings with
any configured target inside range and falls back to standard war outside;
- `UseArcs` prefers an arc over a bolt only at/above `ArcRange`;
- `Void Basic`, `Drain Auto`, and `Harm` are distinct Monsters damage choices;
- `GhostMonsterSpellAttemptCount` counts spell attempts which never start,
while `BlacklistMonsterAttemptCount` counts successful attacks which miss;
- the health-tracker ghost detector is independent and applies to melee,
missile, and magic.
Sources: the official `Virindi_Tank_Standard_Options`,
`Virindi_Tank_Advanced_Options`, `Virindi_Tank_FAQ`, `Options_List`, and
`Virindi_Tank_Changelog` pages listed above.
For UtilityBelt, documentation was checked against the primary source rather
than relying on the generated web page alone. The inspected repository was
`https://gitlab.com/utilitybelt/utilitybelt`, commit
`5fe9825a82f38047737768fd92c61dd47d88e467` (2026-03-05). The grammar is
`UtilityBelt/Lib/Expressions/MetaExpressions.g4`; every method carrying an
`ExpressionMethod` attribute was enumerated. The source is MIT licensed.
This report extends, rather than replaces,
`2026-07-29-vtank-plugin-automation-requirements.md`. That earlier report
already decoded VTank's `.met`, `.nav`, and `.utl` structures from primary
sources and remains the format reference.
## 2. Complete VTank capability map
### 2.1 Combat
VTank is a priority-driven combat controller, not merely an auto-attack loop.
Its supported combat family includes:
- melee, missile, mage, hybrid, two-handed, Void and Summoning characters;
- Life harm/martyr attacks, grenades, lenses, cast-on-strike weapons, streaks;
- automatic damage/weapon choice and monster-specific weapon, offhand and pet
element overrides;
- monster rules with `DEFAULT` plus ordered first-match expressions;
- per-rule priority from ignore (`-1`) through `4`, attack/debuff flags,
damage type, attack height, ring/streak choices, and void curses;
- target selection by distance, angular deviation, or the hybrid method using
angle inside a configurable cutoff and distance outside it;
- target lock, blacklist/retry behavior, and ghost-target retirement after
failed casts or missing health updates;
- debuff scheduling by one target, priority group, or all targets before
attack; spell-level versus skill-based debuff choice and reapply windows;
- automatic ring use by nearby-target density and arc/bolt choice by range;
- melee high/middle/low attacks, automatic or explicit power, Recklessness;
- pet density, element, refill and test behavior.
VTank's macro scheduler checks multiple action lists in priority order. Combat
therefore cannot be implemented as an isolated timer: healing, buffing,
navigation, looting, fellowship assistance and combat all need one arbiter.
### 2.2 Buffing and vitals
- automatic trained attribute/skill buffs, protections, banes, auras,
regeneration and configured extra buffs;
- protection/bane profile sets, exclusions, level/tier selection, signed
skill-over-difficulty thresholds, force buff and idle top-off;
- time-remaining rebuff and persisted item-buff duration knowledge;
- combat, idle and fellowship-helper vital thresholds;
- kits, vital transfers, post-switch recharge behavior and special healing
items;
- self-dispel in response to high-level vulnerabilities.
MossTank's current buff engine already owns the first useful subset: known
self buffs, tier/difficulty choice, in-force enchantment timing, force buff,
and stamina/mana upkeep. It remains plugin policy over host primitives.
### 2.3 Inventory and crafting
- AutoStack and AutoCram;
- pea splitting and priority rules;
- crafting of kits, foods, arrowheads and special ammunition;
- mana-stone acquisition, filling and application to equipped items;
- lockpick selection and use;
- component, consumable, tool and ammunition upkeep.
### 2.4 Looting
- corpse approach/open/retry/timeout/blacklist;
- all/fellow/rare loot modes and priority boosts;
- appraisal/ID wait, unknown-scroll reading and salvage combining;
- a loot-plugin seam, with VTClassic as the canonical ordered, first-match
rule engine over raw and computed item properties;
- actions including no-loot, keep, keep-up-to, salvage, sell, read and custom
user actions.
The host must expose object property bags, appraisal completion and
transaction primitives. Rule ordering and loot-profile policy belong in
MossTank.
### 2.5 Navigation
- circular, linear, once/runback and follow routes;
- points, portals, recalls, pauses, chat, vendor, repeated NPC talk/use,
server-confirmed checkpoints and charged/shift/strafe jumps;
- closest-entry, reversal, arrival/off-course ranges, door use and
follow-around-corners;
- combat/nav priority interaction.
Route storage belongs to the plugin. The host owes move-to, follow, turn,
jump, use and authoritative-arrival primitives.
Direct inspection of the official assembly on 2026-08-27 pinned the route
contract more tightly:
- `eNavType` is Circular, Linear, Target and Once; Once destructively removes
its first completed row and Linear deliberately visits each endpoint once
while flipping direction;
- `eWaypointType` assigns Point/Portal/Recall/Pause/ChatCommand/OpenVendor/
Portal2/UseNPC/Checkpoint/Jump to numeric ids 0..9;
- `fd.cs` turns outside 4°, moves while turning only within 45° beyond 3 m or
15° inside 3 m, and stops at `NavCloseStopRange` (default 2 m);
- `gr.cs` compares the checkpoint against the last server position rather
than client prediction and nudges forward after 15 seconds without an
acknowledgement;
- `gl.cs` records the followed player's path by approximately 9.6 cm and
drops old breadcrumbs when the follower comes within 2.4 m of a later path
segment, preserving follow-around-corners;
- `e9.cs` and `fa.cs` reacquire exact-name/class objects within 2.5 m of the
stored position. Portal2 retries when portal exit remains within 15 m of
its origin; UseNPC repeats until the named NPC tells or gives to the player;
- `b7.cs` is a rule independent of the route node list. `OpenDoors` defaults
false; it IDs doors at 20 m, opens at 4 m, and accepts a lock when Lockpick
is at least `difficulty - 50` using an owned lockpick.
These are plugin policies over additive canonical projections, not a second
movement model. The host applies semantic movement intent through Runtime's
existing command interpreter and supplies the accepted server position needed
only by Checkpoint.
### 2.6 Fellowship and social automation
- tell-driven recruitment and waiting lists;
- fellowship leader/member/state queries and leader replacement voting;
- fellowship healing, corpse permissions and coordinated target/debuff policy;
- multi-client composition through chat rather than a privileged macro API.
### External loot-classifier seam
VTank loads one `LootPluginBase`, asks `DoesPotentialItemNeedID`, and then
calls `GetLootDecision(GameItemInfo)`. Its public result vocabulary is
NoLoot, Keep, Salvage, Sell, Read, User1User5 and KeepUpTo with `Data1` as the
limit. MossTank modernizes discovery into a host-owned classifier registry:
plugins register a namespaced classifier for their own lifetime, while
MossTank remains the corpse/appraisal/pickup/action executor. The selected
engine is durable policy; if it unloads, MossTank returns no classifier match
instead of silently applying the built-in profile.
Direct inspection of the official `hv.cs` also shows that a custom loot
plugin's per-item action is retained only after the item enters owned
inventory and is removed when the item leaves. The modern registry therefore
has matching `OnLooted` and `OnItemRemoved` callbacks. MossTank invokes them
only from authoritative inventory publication/removal, never when pickup is
merely dispatched.
### Options-page scheduler findings
The official Options controls are not merely presentation aliases:
- `fz.cs` runs the ordinary `RebuffTimeRemainingSeconds` rule before combat;
- `cLogic.cs` runs a second `IdleBuffTopoffTimeSeconds` pass only behind
`IdleBuffTopoff`, after attack/loot work has gone idle;
- the PRETARGETAPPROACH `g8` rule navigates only between `AttackDistance` and
`ApproachDistance`, and requires both combat and navigation to be enabled;
- `cm.cs` changes to Peace only as the final no-target/no-work fallback.
The UI displays AC-distance settings multiplied by 240. MossTank stores metres
in its typed controllers and converts only at the VTank option boundary.
### 2.7 Meta state machine
- named states beginning at `Default`;
- state-local rules, each firing once per state entry;
- nested conditions (`All`, `Any`, `Not`) and conditions for chat regex,
inventory, timers, nav state, death, vendors, monsters, buffs, coordinates,
portals, burden, route distance, expressions and captured chat groups;
- actions for state transition, chat, grouped actions, embedded navigation,
call/return stack, expression execution, expression-derived chat, watchdogs,
option read/write and runtime-created views;
- a roughly 293 ms decision cadence plus evaluation when the macro asks for
its next action.
### 2.8 Profiles, commands and companion behavior
- independent settings, navigation, loot and meta profiles; global and
per-character variants; hot loading and automatic persistence;
- command parity for macro state, options, buffing, meta, item testing,
property dumps, monster/spell diagnostics, route editing, attack power and
debug output;
- extensibility equivalent to VTClassic, VI2, item tools, follower/status HUD,
alerts and cross-character inventory. Some belong as separate acdream
plugins, but MossTank's API must permit them without privileged host code.
## 3. Expression language target
### 3.1 Why UtilityBelt is the baseline
VTank expressions are enough to power classic metas, but UtilityBelt preserves
the familiar syntax while adding typed lists and dictionaries, slicing,
higher-order collection functions, broader object queries and more action
primitives. MossTank should implement the UtilityBelt-compatible semantic
superset and offer a VTank compatibility mode for old expressions.
### 3.2 Grammar and evaluation semantics
The audited UtilityBelt grammar supports:
- multiple `;`-separated statements, returning the final result;
- session (`$`), persistent (`@`) and global (`&`) variables;
- decimal and hexadecimal numbers, booleans and two string forms;
- function calls using `name[...]`;
- typed values: number, string, boolean, list, dictionary, coordinate, world
object, stopwatch and UI control;
- list/string/dictionary indexing, slices and negative indices;
- complement, shifts, bitwise operators, exponentiation, arithmetic, regex
match (`#`), comparison, short-circuit `&&` and `||`;
- registered function metadata, arity/type validation and documented return
types;
- collection creation/mutation/copying plus map/filter/reduce/sort/range.
Implementation requirements follow directly: parse into an immutable AST;
compile or interpret without ambient reflection; use explicit value kinds;
short-circuit logical nodes; attach cancellation and an instruction budget;
make all world/action functions capabilities supplied by the MossTank engine;
and serialize only persistent/global variable stores.
### 3.3 Audited UtilityBelt function catalog (260 declarations)
The declaration count includes aliases/overloads. Grouped by capability, the
public names are:
- **language/conversion/math:** `abs`, `acos`, `asin`, `atan`, `atan2`,
`ceiling`, `chr`, `cnumber`, `cos`, `cosh`, `cstr`, `cstrf`, `floor`,
`hexstr`, `iif`, `ifthen`, `isfalse`, `istrue`, `lumavg`, `lumtotal`,
`ord`, `randint`, `round`, `sin`, `sinh`, `sqrt`, `strlen`, `tan`, `tanh`,
`tostring`, `vitae`;
- **variables:** `getvar`, `setvar`, `testvar`, `touchvar`, `clearvar`,
`clearallvars` and the corresponding `pvar` and `gvar` families;
- **execution/chat:** `exec`, `delayexec`, `clearexec`, `echo`, `chatbox`,
`chatboxpaste`;
- **lists:** `listcreate`, `listadd`, `listinsert`, `listremove`,
`listremoveat`, `listgetitem`, `listcontains`, `listindexof`,
`listlastindexof`, `listcopy`, `listreverse`, `listpop`, `listcount`,
`listclear`, `listfilter`, `listmap`, `listreduce`, `listsort`,
`listfromrange`;
- **dictionaries:** `dictcreate`, `dictgetitem`, `dictadditem`, `dicthaskey`,
`dictremovekey`, `dictkeys`, `dictvalues`, `dictsize`, `dictclear`,
`dictcopy`;
- **time/location:** `getdatetimelocal`, `getdatetimeutc`, `getunixtime`,
`getworldname`, `getplayercoordinates`, `getplayerlandblock`,
`getplayerlandcell`, coordinate parse/get/distance/string functions,
stopwatch functions, and the eleven `getgame*`/day/night functions;
- **character:** raw typed property reads, base/buffed skills, training level,
base/current/buffed-max vitals, base/buffed attributes, burden, free slots,
cooldown expiration, account hash and character index;
- **spells/components:** `getknownspells`, `getisspellknown`,
`getcancastspell_buff`, `getcancastspell_hunt`, `getspellexpiration`,
`getspellexpirationbyname`, `spelldata`, `spellname`, `componentdata`,
`componentname`;
- **world objects:** validity/data/ID-time, raw typed properties, identity,
health/vitals, spells, coordinates, selection/player/open-container, door
state, nearest monster/door/by class/name/template, and `wobjectfindall*`
variants over world, landscape, inventory and containers;
- **actions:** select, use, apply, give, equip wand, cast, cast-on-target,
move, split and drop;
- **combat/movement:** combat state get/set, busy state, equipped weapon type,
heading/get-heading-to, motion get/set/clear and portal-state query;
- **inventory/loot/salvage:** counts by name/regex/type, give-profile,
unopened corpse queries, `ustadd`, `ustopen`, `ustsalvage`;
- **fellowship/quest/XP:** thirteen fellowship queries, quest state/progress,
seven XP-meter operations;
- **UI/options/network/login:** status HUD, view/control get/set/visibility,
VT option/meta get/set, macro status, UtilityBelt options, regex capture,
network clients and next-login control.
This catalog is a compatibility test ledger. Each name must eventually be
implemented, deliberately aliased, or marked unsupported with a documented
reason; silent omission is not acceptable.
## 4. acdream mapping after the 2026-08 campaigns
The 2026-07 report's architecture remains correct, but its gap table is stale.
The Runtime now owns inventory transactions, selection, combat mode and power
state, casting, fellowship, allegiance, vendor and secure-trade state. MossTank
already consumes a small BCL-only `IAutomationSurface` for vitals, skills,
spells, enchantments, casting and local chat.
The gaps relevant to the first autocombat milestone are narrower:
| Need | Canonical owner today | Plugin gap |
|---|---|---|
| hostile query and live position | `RuntimeEntityDirectory` + `ClientObjectTable` | no target snapshot/query |
| health and selected target | `RuntimeActionState` | no combat view |
| melee/missile charge/release | `RuntimeCombatAttackState` | no command surface |
| combat-mode transition | `RuntimeCombatModeState` | no command surface |
| known offensive spells | `Spellbook` | only self buffs are enumerated |
| target-specific cast | selection + `RuntimeSpellCastState` | possible only by composing two old services |
| polished plugin controls | retained `IUiRegistry` markup | markup lacks bound visibility/enabled/style affordances |
The first implementation therefore does not need a second runtime bridge or a
second object model. It needs a narrow additive projection of those exact
owners.
## 5. Decisions for MossTank
1. MossTank remains an ordinary plugin. It never references App, Runtime,
rendering, networking or DAT assemblies.
2. The host API exposes snapshots and attempt-style commands; MossTank owns
target scoring, rule ordering, spell/attack choice and timing.
3. The first combat milestone supports melee, missile and direct offensive
magic, target lock, range/angle/hybrid scoring, priority rules, attack
height and power. Navigation, weapon swapping, debuffs, vulnerabilities,
pets and monster expressions are later combat slices, not hidden stubs.
4. Expressions will use UtilityBelt's richer typed semantics. Compatibility
is defined by parser/evaluator tests and the audited function ledger, not by
copying UtilityBelt implementation code.
5. Native MossTank profiles will be versioned JSON. Importers for VTank files
can be added later without constraining the internal model.
6. The UI uses acdream's retained plugin UI contract. Missing generic controls
should improve that contract/markup rather than making MossTank depend on a
presentation implementation.
### 5.1 Follow-up implementation findings (2026-08-27)
VTank's official `e0.d(name)` first looks in `MonsterDamageOverrides`, then
maps the monster to `SpeciesDamages`; `ga.g(...)` walks that ordered preference
list and finally tries the unlisted elements 0..6. acdream already projects
retail `CreatureType` as `PluginCombatTarget.SpeciesId`, so MossTank can bypass
VTank's name-to-species compatibility table while preserving the same ordered
damage result. Exact name overrides still win. The imported official feed has
59 overrides and 103 species rows.
### 5.2 Official inventory and loot findings (2026-08-27)
The official VTank assembly and its GameInfoDB were inspected rather than
inferring behavior from the UI labels:
- `el.cs`/`cf.cs` supply 757 exact craft rows; prerequisites are recursive and
share the canonical item-use transaction;
- `fo.cs` identifies every corpse before selection, parses `Killed by ...`,
admits the player's own corpse immediately, admits a Share Loot fellow
immediately, waits 100 seconds for a non-sharing fellow or unrelated public
corpse, and never crosses ownership on another player's rare-generating
corpse;
- the default corpse-open retry contract is 30 attempts, then a 200-second
blacklist; completed corpse records expire after 60 minutes;
- `hv.cs` applies the ordered loot rule first, then falls back to readable
unknown scrolls and automatic mana-stone/tank acquisition;
- `dy.cs` proves that ManaTank is a mana-bearing donor target, not a worn-item
recharge consumable. A ManaStone is used on that donor when its mana is at
least `ManaTankMinimumMana` (default 1000);
- `c7.cs` combines only same-material salvage bags in exact workmanship bands
`<7`, `7<9`, `9<10`, and exactly `10`; one bugged source is abandoned after
40 failed combine attempts;
- `gmSalvageUI::Salvage` calls
`CM_Inventory::Event_CreateTinkeringTool`: game action `0x027D`, tool id,
then `PackableList<unsigned long>` (count plus ordered item ids). This same
operation handles ordinary source salvage and salvage-bag combination.
The native implementation keeps settings, loot, route, and meta documents
independent, matching VTank's profile model while using versioned JSON as the
working format. It also emits and imports exact compatibility files: `uTank2
NAV 1.2`, CondAct `.met`, and VTClassic `UTL 1` (plus legacy UTL v0 reads).
The UTL port preserves unknown length-delimited requirement and extra-block
payloads, executes the complete 31-type requirement vocabulary, and carries
the `SalvageCombine` material ranges/value modes into the live combine planner.
VTClassic's color rules use the original ordered ObjDesc subpalettes and the
original sample index `length*16 + offset*32 + 8`, resolved from portal DAT
palette colors rather than approximated from icon pixels.
Profiles are implemented over manifest-scoped JSON with exact VTank files as
an interchange/export layer: `By char` hashes the canonical character name into a distinct
document, named profiles are explicit shared snapshots, and the index records
owner plus per-character active selection. Create/copy/clear/select all hot-
load the same mutable policy owners already borrowed by the controllers. The
generic retained markup contract gained editable fields and retail dropdown
menus for this editor; later Monsters, Loot, Route, and Meta editors reuse the
same controls.
## 6. Acceptance boundary for “autocombat ported”
The milestone is complete when an in-world MossTank panel can enable/disable
combat, periodically capture canonical hostile targets, preserve a valid
locked target, choose a target by configured range/angle/hybrid policy and
priority, enter the equipped default combat mode, drive retail's physical
press/charge/release state machine at configured height/power, or cast the
best usable learned direct offensive spell in magic mode. It must stop cleanly
on session loss, invalid/dead/out-of-range targets, and user disable; it must
not duplicate Runtime state or issue overlapping requests.
Full VTank parity is the campaign target. This acceptance boundary is only the
first executable slice requested for this work session.
## 7. Official binary combat-item findings (2026-08-27)
The official `vt.tar.gz` update was decompiled for behavior research and the
live GameInfoDB v9 feed was read directly. The decisive implementations are
`dz.cs` (debuff source selection), `ga.cs` (item classification), `gs.cs`
(caster-item confirmation), `bo.cs` (physical/proc confirmation), and `hi.cs`
(attack-power policy).
- `dz.b.CompareTo` ranks spell quality then source skill/spellcraft for
`SpellLevel`, reverses those two for `Skill`, and gives a learned spell the
final tie. Spell quality is normally spell difficulty.
- Caster items activate on the target. Melee/missile proc weapons are equipped
and repeatedly attack at power 0/1 respectively. Neither path counts as
applied until color-7 combat chat matches `^You cast (.*) on .*$`.
- Grenades are missile-class items with CombatUse 0 and `Phial` in the name;
the official database contains exactly 72 names across eight material tiers,
with Alchemy requirements 75..400 and spellcraft 100..520.
- Normal physical attack power is not a smooth heuristic. `hi.cs` emits the
exact 0, .2, .49, .5 or 1 values for slash/pierce hybrid arrangements, then
clamps to .11..90 when trained Recklessness is enabled.
These findings require three host facts VTank formerly obtained through
Decal: retained per-item appraisal SpellBooks, ordered transcript capture, and
an explicit combat-mode command. They are additive BCL plugin contracts;
MossTank retains all source-choice and retry policy.

View file

@ -1,745 +0,0 @@
# Retail inventory interaction audit
**Date:** 2026-08-26
**Scope:** Selection, status text, single/double/right click, drag/drop,
container movement, ground pickup/drop, equipping, stack splitting, vendor
staging, failure feedback, and SpewBox routing.
**Change policy:** Audit followed by implementation in the same worktree.
**Source snapshot:** `0c699240`, plus the already-present working-tree fixes for
#444, #445, #446, #447, and #449. Those fixes are assessed as found; this
report does not claim that they have been committed or user-accepted.
## Implementation closeout — 2026-08-26
Slices 14 below are implemented and automated-test covered. Gameplay
refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are
complete; full move/drop/wield are request-first with pending projections;
owned-container and vendor-row mouse behavior follows the named retail
handlers; hover and release share one side-effect-free legality policy; and
local item-policy wording is composed from retail's exact literals.
The last toolbar uncertainty is also resolved. Raw retail bytes at
`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal
`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch
now renders that exact stack/name/total shape. The final AutoWield fallback was
also corrected: retail does not print the invented “That slot is already in
use”; with automatic unblocking enabled it moves the preferred occupied-slot
item to the backpack, waits for the authoritative move, then retries the
wield. Slice 5 remains deliberately deferred as the single combined connected
owner gate.
## Audited root causes (now fixed)
The inventory implementation was not missing one isolated rule. Most individual
operations existed and used the correct wire messages, but three seams made the
whole experience feel intermittent:
1. **Some retail-local refusal text was routed to a dead production callback.**
`ItemInteractionController` and `AutoWieldController` used a `toast` callback
for a substantial class of local rejections while `GameWindow` supplied
`null`. Retail sends these messages to the `ClientLocal` channel, which is
the SpewBox in acdream. The result was a real silent-failure class, not merely
different wording.
2. **Full moves, world drops, and wield operations mutated canonical inventory
state before the server accepted them.** Retail normally leaves the source
canonical object in place, adds a waiting/ghost projection at the intended
destination, and commits only after the authoritative object update. The
old optimistic mutation was reversible, but selection, capacity,
paperdoll, vendor, and other observers could see a transient state that never
existed on the server. This was the largest structural flakiness risk.
3. **Several list-specific mouse behaviors did not match retail.** In
particular, staged vendor rows could not be double-clicked or dragged to
remove them, staged rows lacked right-click examine, and owned side-pack
double-click/open ordering differed from retail.
The wire builders, global selection/split model, merge-first rule, request gate,
most right-click examine paths, normal item double-click use/equip, external
container pickup, paperdoll placement validation, and the newly repaired
vendor-split/main-pack-capacity paths are broadly aligned with retail.
The implementation was executed in this order:
1. Route every local item refusal through `ClientLocal`/SpewBox.
2. Replace canonical optimistic movement with retail-style pending projections.
3. Close the vendor staged-row and owned-container input differences.
4. Deepen hover/drop legality and finish exact status/failure text parity.
5. Run one connected interaction matrix across inventory, paperdoll, ground,
external containers, and vendors.
## Method and evidence standard
This audit used four evidence layers:
- The September 2013 named retail pseudo-C under
`docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named
class and method before relying on older address-only material.
- Existing focused retail notes under `docs/research/`, especially the item,
drag, give, world-drop, use/autowear, and vendor investigations.
- The current production controllers, Runtime owners, UI input dispatch, wire
request builders, and communication routing.
- Existing focused tests, used to distinguish implemented intent from behavior
that is not currently protected.
Verdicts in this report mean:
- **Match:** the important retail behavior and ownership rule are present.
- **Partial:** the common path matches, but a retail branch, presentation rule,
or failure path is absent.
- **Mismatch:** direct retail evidence contradicts the current behavior.
- **Risk:** the mechanism differs in a way likely to produce transient or race
defects, but this audit does not assert a particular live symptom without a
connected reproduction.
- **Gate pending:** a code fix exists in the working tree and has automated
coverage, but the owner has not yet accepted the live behavior.
## Retail reference model
### One selected object and one split quantity
Retail has a client-global selected object. Clicking an item selects it;
right-click first selects it and then examines it; beginning a drag selects it
if it was not already selected. The toolbar observes that global selection and
shows the name, stack quantity, and split controls.
The split quantity is also global and applies only when the dragged/requested
object is the selected object. An unselected stack always means the full stack.
Changing selection resets/reseeds the split amount. Vendor-owned selected
stacks use a different initial amount from normal owned stacks.
Primary anchors:
- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50`
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380`
- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C
### Mouse-down establishes intent; click completion performs list action
For a retail item-list entry, left press first gives target mode a chance to
consume the object. Otherwise it selects the object. A container-list entry
also opens that child container and updates its open indicator in this same
item-list message path.
Right press selects and examines. Double-click invokes generic `UseObject` for
ordinary list items, but the generic double-use path is suppressed for an
owned `containerList` entry. The ground/external root is explicitly allowed.
This distinction matters: a side pack is opened as a container, not opened and
then generically used as an ordinary item on the second click.
### Dragging is a request with pending presentation
Beginning a physical-item drag produces a source waiting/ghost state. Vendor,
salvage, and shortcut lists are special list types and do not use the same
physical-source waiting ghost.
Hover is advisory and silent. Release reruns legality with feedback enabled.
For a normal container move, retail retains the canonical source ownership and
adds a pending destination projection. The server's authoritative object update
commits the move. Rejection removes the pending projection and prints the local
failure. This same general principle appears in world placement and split-to-
world handling.
Primary anchors:
- `UIElement_ItemList::BeginDrag` at `0x004E32D0`
- `UIElement_ItemList::DragOver` at `0x004E3400`
- `UIElement_ItemList::AcceptDragObject` at `0x004E4250`
- `UIElement_ItemList::HandleDropRelease` at `0x004E4790`
- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0`
- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0`
- `ItemHolder::WillItemFitInContainer` at `0x00587D60`
- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90`
### Drop target dispatch is ordered
Retail's three-dimensional drop/give dispatcher follows this practical order:
1. Require an owned, movable source that is not currently in trade.
2. Dropping on self means the main backpack.
3. Target zero means ground placement or split-to-world.
4. Try stack merge before treating the target as a container.
5. A player target opens/routes through secure trade.
6. A creature target uses give-item behavior.
7. A container target must be open, unlocked, and legal.
8. Vendor lists use their own staging rules.
9. Otherwise resolve as a ground placement or refuse it.
`AttemptMerge` uses the selected split amount, clamps to target capacity, sends
the merge request, and selects the target stack. Give-item is request-only; it
does not optimistically remove the source from canonical inventory.
Primary anchors:
- `ItemHolder::AttemptMerge` at `0x005878F0`
- `ItemHolder::AttemptPlaceIn3D` at `0x00588600`
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
### Use and equip
Generic double-click use passes through `ItemHolder::DetermineUseResult` and
`ItemHolder::UseObject`, with a short use throttle. The item is classified as
direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use.
Retail locally refuses invalid states and prints a `ClientLocal` message.
Paperdoll 3D clicks and discrete equipment-slot lists share the same global
selection/examine model. Dropping on a paperdoll location validates the exact
location, then chooses auto-wear or auto-wield behavior. Clothing overlap can
be rejected locally; weapon replacement has different rules.
Primary anchors:
- `ItemHolder::DetermineUseResult` at `0x00588460`
- `ItemHolder::UseObject` at `0x00588A80`
- `CPlayerSystem::UsingItem` at `0x00562F70`
- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30`
- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10`
- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70`
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
### Vendor rows are active item lists
Direct named-retail evidence establishes these behaviors:
- Double-clicking a vendor browse row buys one item.
- Double-clicking a staged buying row removes it and prints
“Removing %s from shopping list” through `ClientLocal`.
- Double-clicking a staged selling row removes it, clears its sell state, and
prints the same form of message.
- Dragging an already-staged selling row removes it from the staged list.
- If a partial split is selected while dragging a staged selling row, retail
refuses to split that row, prints “You cannot split items from this panel”,
and resets the split control to the stack maximum.
- A new partial-stack drag into the Selling list sends a split request, creates
a temporary staged row, and replaces that row when the new matching object
arrives.
- Hover rejection is silent; release rejection prints to `ClientLocal`.
Primary anchors:
- `gmVendorUI::HandleMousePresses` at `0x004C40D0`
- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380`
- `VendorSellUI::DragItemAcceptable` at `0x004C20C0`
- `VendorSellUI::AcceptDragObject` at `0x004C4F00`
- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0`
This corrects an older project research conclusion: browse-row double-click
buy is retail behavior. It is not an acdream modernization.
### Feedback destination
Retail item-policy and request-failure messages are sent on the local client
text channel. In acdream, `RuntimeCommunicationState.AddText` maps
`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the
chat transcript and does not apply a chat timestamp.
Hover failures are normally silent. Release/action failures are not. Server
request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed`
at `0x0058EAE0`, including move and wield failures.
## Current acdream ownership and routing
The relevant production flow is:
```text
UiRoot / UiItemSlot
-> InventoryController | ExternalContainerController | PaperdollController
| VendorUiController | SelectedObjectController
-> ItemInteractionController / AutoWieldController
-> RuntimeInventoryState + RuntimeActionState transactions
-> ClientObjectTable (canonical object ownership)
-> outbound request builder
-> authoritative object update / request failure
-> RuntimeCommunicationState.ClientLocal -> SpewBox
```
Important owners:
- `SelectionState` is the sole selected-object owner shared by inventory,
paperdoll, vendor, world selection, and toolbar status.
- `RuntimeInventoryState` owns external-container state, item-use transaction
state, shared busy/request state, split/pending placement state, and borrows
the canonical `ClientObjectTable`.
- `SelectedObjectController` projects selection into the authored toolbar and
owns the split-slider presentation.
- `ItemInteractionController` classifies use/drop/give/move operations and
sends requests.
- `InventoryController`, `ExternalContainerController`, `PaperdollController`,
and `VendorUiController` own their list-specific input and projections.
This ownership shape aligns with the architecture document. The central issue
is not duplicate state; it is which state is mutated before acknowledgement.
## Behavior matrix
| Surface/action | Retail | Current acdream | Verdict |
|---|---|---|---|
| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match |
| Ordinary item single click | Select; no generic use | Mouse-down selects | Match |
| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match |
| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented |
| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented |
| Inventory right-click | Select, then examine | Select and examine | Match |
| Drag lift | Select if needed; source ghost | Selects and ghosts | Match |
| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented |
| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented |
| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match |
| Partial move to container | Split request; wait for authoritative object | Request-only | Match |
| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented |
| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match |
| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match |
| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match |
| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented |
| Give to creature | Request-only; selected split amount | Request-only | Match |
| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match |
| Paperdoll click/right-click | Global select/examine | Global select/examine | Match |
| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match |
| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented |
| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented |
| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented |
| Split applicability | Only selected stack uses global quantity | Same | Match |
| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match |
| Vendor browse double | Buy one | Buy one | Match |
| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending |
| Vendor hover refusal | Silent | Silent | Match |
| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match |
| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented |
| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented |
| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented |
| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented |
| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending |
| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented |
## Findings
### F1 — local inventory refusals can be completely silent
**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects
interface text, system text, or the test fallback in that order.
**Priority:** P0
**Confidence:** Confirmed by production composition
`ItemInteractionController` uses two different presentation routes:
- `_systemMessage` / `_interfaceText`, which are wired to
`RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox.
- `_toast`, used by many local policy refusals.
`InteractionRetainedUiComposition` forwards its `toast` dependency, but
`GameWindow` currently sets the production composition toast to `null` after
the developer-toast surface was removed. Consequently, the local rejection
still aborts the action, but the user receives no explanation.
Affected classes include invalid item use, missing use target, trade/wield
requirements, locked or unsuitable targets, invalid move/give/drop states,
midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should
be frozen in a focused message-routing test before changing it.
Retail evidence is unambiguous: these are local client text messages and belong
in SpewBox, not a transient developer toast.
**Future fix:** remove the semantic split for gameplay failure text. Give item
controllers one `ClientLocal` sink and reserve any visual toast mechanism for
non-retail developer/launcher notifications.
### F2 — optimistic canonical moves expose impossible intermediate state
**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch
requests without mutating canonical ownership; pending source/destination
presentation converges on confirmation, failure, and reset.
**Priority:** P0 architectural correction
**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates
The full-stack internal move and world-drop paths use optimistic operations
against the canonical object table. Full wield uses the same pattern. Failure
rollback exists, but all borrowers can observe the speculative state:
- selection and toolbar status;
- loose-item and carried-container capacity;
- paperdoll slots;
- vendor sell eligibility/staging;
- external-container views;
- plugins and Runtime views.
Retail instead keeps source canonical ownership stable and uses waiting/ghost
presentation at the intended destination until the server update arrives.
This does not prove that every reported intermittent inventory symptom comes
from this seam. It does explain why otherwise-correct controllers can disagree
briefly and why a rejection/late response/re-entrant action can make the UI feel
flaky.
**Future fix:** model full move/drop/wield like the existing request-only split,
give, ground-pickup, and external-container paths. Store a generation-scoped
pending placement intent and presentation ghost, send the request, and let the
authoritative update commit canonical ownership. On failure/timeout/reset,
remove only the pending presentation.
### F3 — vendor staged-row removal behavior is missing
**Resolution:** CLOSED IN CODE — staged rows implement the retail
double-click, right-click, drag-lift, message, and split-reset branches.
**Priority:** P1
**Confidence:** Confirmed by direct named-retail functions
Current staged Buying and Selling rows only bind selection. They have no
double-click removal. Selling rows also disable drag source behavior.
Retail supports:
- double-click staged Buying to remove;
- double-click staged Selling to remove and clear sell state;
- drag staged Selling to remove;
- a precise ClientLocal removal line;
- a partial-split refusal/reset when dragging from the staged Selling list.
**Future fix:** add list-role-specific actions rather than routing these rows
through generic item activation. Protect each action with unit tests that also
assert selection, sell-state cleanup, totals, and exact SpewBox routing.
### F4 — owned side-pack click/double-click sequencing differs
**Resolution:** CLOSED IN CODE — carried containers open on press and the
generic double-use route is suppressed for that list role.
**Priority:** P1
**Confidence:** Confirmed structural mismatch
Retail opens a carried child container in the item-list press handler and
suppresses generic double-click use for a `containerList` item. acdream selects
on mouse-down, opens on completed click, and binds generic activation to double
click for every inventory cell. `UiRoot` emits the second click before the
double-click event, so a double-click can both open and activate the pack.
This is a plausible source of redundant requests and awkward drag/open
interactions. It should be fixed by explicit item-list role, not by a global
double-click timing change, because ordinary items and the external-container
root intentionally retain double-click use/open behavior.
### F5 — hover acceptance is less strict than release/server legality
**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the
shared silent-hover/speaking-release decision for owned and external lists.
**Priority:** P1/P2
**Confidence:** Confirmed code difference
Inventory-grid hover mostly checks list role, basic object class, and capacity.
External-container hover is broader still. Retail's predicates incorporate
ownership, trade state, source/destination identity, real carrying-container
restrictions, open/locked state, destination capacity type, and other legal
conditions.
The practical symptom is a green cursor followed by a refusal or apparent
no-op on release. Hover must remain silent, but its boolean should be produced
from the same pure legality decision used at release.
**Future fix:** extract one side-effect-free placement decision that returns a
reason code. Hover consumes only allowed/denied; release converts the same
reason to exact ClientLocal text.
### F6 — selected status lacks retail's owned-coin special case
**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves
the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the
player's `CoinValue` and uses that exact branch for owned WCID 273 stacks.
**Priority:** P2
**Confidence:** Byte-resolved from the PDB-matched retail executable
Normal current text—name for a singleton and `{stackSize} {name}` for a
stack—matches the main retail branch. Retail has an additional owned-coinstack
formatting branch that derives a total/value-aware display and name. The
current controller always uses the generic stack prefix.
Binary inspection resolves the apparent vtable-symbol artifact: the raw call
site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name,
and the player's integer `CoinValue` as its three arguments.
### F7 — request failure coverage omits move and wield kinds
**Resolution:** CLOSED IN CODE — both request kinds are represented and route
through the item-aware retail failure composer.
**Priority:** P2
**Confidence:** Confirmed enum/composer gap
Retail's `ServerSaysAttemptFailed` includes move and wield result families.
The current request-failure model and `InventoryFailureMessages` cover merge,
split, pickup, put, drop, and give, but do not represent the retail move/wield
families. A server-side failure in those operations therefore cannot produce
the exact item-aware retail sentence through the common composer.
### F8 — current retail-divergence documentation is wrong about vendor double-click
**Resolution:** CLOSED — the older research is corrected and AP-171 retired.
**Priority:** Documentation correction before implementation
**Confidence:** Confirmed by direct named-retail evidence
Older vendor research and AP-171 characterize double-click browse-row purchase
as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls
`BuySingleItem` on the retail Items-list double-click. Current browse behavior
is correct; the documentation is not. Leaving this claim in the register risks
a future parity cleanup deleting a retail feature.
### F9 — #445 and #449 need connected acceptance, not more inference
**Priority:** Gate now
**Confidence:** Automated fixes present
- #445 now uses the selected split quantity for vendor selling, creates a
temporary staged row, and replaces it when the authoritative split object
arrives.
- #449 now counts loose items separately from carried container objects when
deciding whether the main backpack is full.
Both have focused tests in the current working tree. Neither should be marked
closed until a live server gate covers success, refusal, repeated action, and
selection changes.
### F10 — paperdoll disappearance is a separate rendering/residency defect
**Priority:** Keep separate from transaction fixes
**Confidence:** Existing issue #443
The intermittent missing paperdoll that heals after a delay is tracked as
paperdoll first-open/residency behavior. It can make a correct equip transaction
look broken, so it belongs in the combined user gate, but it should not be
folded into inventory ownership or input logic without evidence.
## SpewBox contract
The following should appear in the SpewBox through `ClientLocal` when the user
commits the action and it is refused or changed:
- invalid use/equip/wield state;
- “choose a target” or invalid target;
- cannot move/drop/give an item;
- locked, closed, full, or otherwise illegal destination;
- merge/split/pickup/put/drop/give/move/wield request failure;
- vendor item cannot be sold or split in that list;
- removal from a vendor shopping/selling list;
- automatic removal of conflicting wear items where retail reports it;
- midair or other locally cancelled placement when retail reports it.
The following should be silent:
- merely hovering a rejected drop target;
- moving the pointer away without releasing;
- ordinary selection changes;
- beginning a legal drag.
These messages should not be duplicated into the normal chat log and should
not gain chat timestamps. That is already how `ClientLocal` behaves in the
communication owner.
## Existing automated coverage
The repository already has strong narrow coverage in:
- `InventoryControllerTests`: population, selection, open/right-click,
drag/ghost, pending pickup, split, merge, capacity, rollback, and #449.
- `ExternalContainerControllerTests`: root/nested behavior, selection,
right-click, partial split, and pending gates.
- `PaperdollControllerTests`: selection, examine, drag, and wield placement.
- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split
initialization.
- `VendorUiControllerTests`: browse, buy quantities, selection/examine,
staging, partial vendor split/failure, rejection feedback, and alternate
currency.
- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack
behavior, failures, and transaction lifecycle.
- Runtime inventory tests: request ownership, reset, and lifecycle behavior.
The pre-implementation test suite was strongest at proving controller-local
intent. The implementation program below adds the missing transaction and
cross-controller coverage.
## Automated gates added by the implementation
The implementation adds or updates coverage for the following:
1. A production-composition test proving every local policy rejection reaches
`ClientLocal`/SpewBox and no gameplay failure depends on a toast callback.
2. Owned side-pack single/double-click tests proving one open action and no
generic use request, including the second-click event order.
3. Vendor staged Buying and Selling double-click removal tests with exact
selection, totals, state cleanup, and message assertions.
4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests.
5. Staged vendor-row right-click select/examine tests.
6. A table-driven pure legality test shared by hover and release for inventory,
external container, ground, player, creature, vendor, self, locked container,
full item slots, and full container slots.
7. Owned coinstack toolbar-status parity using the byte-resolved exact format.
8. Move and wield authoritative failure-composition tests.
9. Transaction-observer tests proving canonical ownership does not change
before acknowledgement while selection, capacity, vendor, and paperdoll
borrow the same state.
10. Re-entrant sequences: drag while a request is pending, selection change
during split, rejection after container close, late response after session
reset, and repeated action after rollback.
## Executed implementation program
### Slice 1 — feedback integrity — COMPLETE
- Replace gameplay `toast` refusal calls with the shared ClientLocal sink.
- Add the missing move/wield failure kinds and exact item-aware compositions.
- Freeze hover-silent versus release-speaks behavior.
- Correct the vendor double-click documentation claim.
This is small, high-confidence, and immediately turns “nothing happened” into
an actionable player explanation.
### Slice 2 — authoritative placement ownership — COMPLETE
- Introduce one generation-scoped pending placement record for full move,
world drop, and wield.
- Preserve canonical source ownership until the authoritative object update.
- Project source waiting/ghost and destination pending visuals separately.
- Converge success, refusal, timeout, disconnect, and late-response cleanup.
- Prove all borrowed observers see either pre-commit or committed state, never
a speculative canonical move.
This is the most important solidity work and should receive dual review because
it crosses Runtime ownership and retained presentation.
### Slice 3 — item-list mouse parity — COMPLETE
- Make carried-container press/open and double-click suppression explicit.
- Add staged vendor double-click removal.
- Add staged Selling drag-to-remove and split reset/refusal.
- Restore right-click select/examine consistently across vendor list roles.
### Slice 4 — shared legality and exact presentation — COMPLETE
- Unify hover/release placement decisions with reason codes.
- Add the owned-coinstack toolbar branch after capturing exact retail text.
- Reconcile hard-coded local item wording with DAT-backed retail strings.
### Automated verification — COMPLETE
- Focused inventory/external-container/paperdoll/vendor/selection/item-use
matrix: 328 passed, 0 failed.
- Cross-controller retained-UI interaction flow: 10 passed, 0 failed.
- Complete Release build: 0 warnings, 0 errors.
- Repository hermetic lane (the exact release filter, serial execution):
15,755 passed, 0 skipped, 0 failed across 14 test assemblies.
The repository wrapper's project-consistency preflight explicitly excludes the
tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They
compile against a separately installed ACE server and intentionally remain
outside `AcDream.slnx`; the portable product graph still owns every other
project under `src/`, `tests/`, and `tools/`.
### Slice 5 — connected closure — DEFERRED OWNER GATE
Run the manual matrix below against ACE using an exact built binary and retain
logs/screenshots for failures. Close #445 and #449 only after their rows pass.
Keep #443 independent unless the evidence links paperdoll rendering to an
inventory acknowledgement.
## Connected manual matrix
Use one normal item, one wearable item, one wieldable item, two mergeable
stacks, one side pack, a full main backpack, a full side pack, an open chest,
a locked/closed container if available, a creature/player target, and a vendor
with normal and alternate currency.
1. Single-click each item/list type; verify selection border and exact status.
2. Right-click inventory, side-pack, external-container, paperdoll, browse,
Buying, and Selling rows; verify selection and examine.
3. Double-click ordinary usable, wearable, wieldable, and unusable items;
verify one request and correct SpewBox refusal where applicable.
4. Single- and double-click a carried side pack; verify one open action, no
redundant generic use, and stable selection.
5. Drag a full item between main pack and side pack; observe source/destination
before response, after success, and after forced rejection.
6. Fill a side pack, reject a move, free one slot, and retry immediately.
7. Fill the main pack with loose items while carrying side packs; verify item
and container capacities independently (#449).
8. Merge full and partial stacks; verify selected split amount, target
selection, source remainder, and full-target refusal text.
9. Split to an inventory container, external container, creature, ground, and
vendor; change selection while the request is pending.
10. Drop full and partial stacks to ground; verify ghost/pending behavior,
selected arriving object, rejection cleanup, and no duplicate item.
11. Pick up from ground into a nearly full destination, then retry after
freeing capacity.
12. Equip by double-click and by paperdoll drag; test clothing conflict and
weapon replacement. Verify source/paperdoll state before acknowledgement.
13. Drag full and partial stacks to vendor Selling; verify exact quantities,
temp-row replacement, totals, and #445 behavior.
14. Double-click staged Buying and Selling rows to remove them; verify SpewBox
text and state cleanup.
15. Drag a staged Selling row to remove it; repeat with a partial split selected
and verify refusal plus slider reset.
16. Complete/cancel transactions in normal and alternate currency; verify
currency balance refresh (#444) and selection/status stability.
17. Repeat representative actions while another inventory request is pending,
immediately after rejection, and immediately after reopening a container.
18. Log out/portal/re-enter with a pending or recently completed interaction;
verify the request ledger and pending projections converge to zero.
For every refused release/action, record whether the cursor was green/red,
whether a SpewBox line appeared, the exact line, and whether canonical item
ownership changed before the server response.
## Evidence index
Retail research already in the tree:
- `docs/research/deepdives/r06-items-inventory.md`
- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`
- `docs/research/2026-07-13-retail-give-item-pseudocode.md`
- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md`
- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`
- `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
Primary current implementation surfaces:
- `src/AcDream.App/UI/UiRoot.cs`
- `src/AcDream.App/UI/UiItemSlot.cs`
- `src/AcDream.App/UI/ItemInteractionController.cs`
- `src/AcDream.App/UI/Layout/InventoryController.cs`
- `src/AcDream.App/UI/Layout/ExternalContainerController.cs`
- `src/AcDream.App/UI/Layout/PaperdollController.cs`
- `src/AcDream.App/UI/Layout/SelectedObjectController.cs`
- `src/AcDream.App/UI/Layout/VendorUiController.cs`
- `src/AcDream.App/UI/AutoWieldController.cs`
- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
- `src/AcDream.Core/Items/ItemInteractionPolicy.cs`
- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`
- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs`
## Closure statement
The retail-backed work order is implemented through Slice 4. The code now has
one ClientLocal feedback route, request-first authoritative placement,
list-role-specific retail mouse behavior, shared placement legality, complete
move/wield failure composition, exact local-policy literals, and the exact
owned-coinstack status format. Occupied-slot AutoWield now also follows retail's
move-confirm-retry transaction instead of emitting an invented refusal. The
complete hermetic automated lane is green. No connected acceptance is claimed
here; the combined owner gate remains the final closure step, and #443 remains
an independent private-viewport residency issue.

View file

@ -1,115 +0,0 @@
# Retail keyboard defaults and routing audit — 2026-08-26
## Verdict
The code gate for #446 now covers all 306 user-bindable rows in the installed
Sept-2013 EoR ActionMap. Each row has a distinct `InputAction`, appears enabled
in Configure Keyboard, persists through retail-compatible named `.keymap`
profiles, and reaches
a concrete subsystem consumer. The exact installed-DAT default chord set has
zero exceptions. The remaining gate is a connected visual/behavior pass and a
fresh-process persistence check.
The approved acdream extension is deliberately retained: mouse-wheel chase
zoom may pull back to 40 m. It does not change the retail keyboard defaults or
the keypad camera actions.
## Oracles
- `docs/research/named-retail/retail-default.keymap.txt` and installed
`client_portal.dat` ActionMap DID `0x26000000`: the 306 rows, default chords,
activation types, input contexts, and `ConflictingMaps` relationships.
- Installed MasterInputMaps `0x14000000` and `0x14000002`: non-bindable system
and mouse commands.
- `ClientUISystem::OnAction @0x00564B90`: Escape priority.
- `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800` and
`ControlNameMapper::LoadSemantics`: displayed keyboard/mouse names.
- `ACCmdInterp::InitializeEmoteInputActionHash @0x0058B510`: all 87 emote
action-to-motion mappings.
- `CPlayerSystem::SelectNext @0x0055F9A0`: selection-cycle filtering and
opened-corpse behavior.
## Exact ActionMap coverage
| Retail input map | Rows | Consumer |
|---|---:|---|
| Movement | 14 | Runtime movement owner, including four postures |
| Camera + alternate camera | 22 | Held camera input, presets, alternate-scope modifier, instant mouse look |
| Combat + melee + missile + magic | 32 | Runtime combat attack owner and spellcasting controller, including spell slots 112 |
| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` |
| Item selection | 26 | Selection controller/query and canonical inventory interaction state |
| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands |
| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes |
| Quickslots | 28 | Toolbar use/select/create routes, including slots 1018 |
| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime |
| **Total** | **306** | **306 distinct live identities** |
The low MasterInputMap entries such as bare Escape and raw mouse event
commands are intentionally not Configure Keyboard rows in retail and are not
counted among the 306. Unknown rows from a future DAT can still round-trip in
the compatibility sibling store, but the installed EoR DAT has no such row and
shows no dimmed/store-only keyboard entry.
## Behavior completed
- Defaults are an exact installed-DAT transcription, including bare
`LeftShift`; device, modifier, activation, and scope all match.
- Primary and alternate camera maps remain distinct rebind targets even where
retail reuses an action id. The alternate modifier changes the active camera
scope without aliasing saved bindings.
- Same physical chord may fire each distinct retail action allowed by the
ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows
multicast instead of one silently replacing the other.
- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee,
missile, and magic key cluster remains legal; true conflicts still prompt.
- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons.
Physical modifier self-bits are normalized, so binding LeftShift does not
accidentally become Shift+LeftShift. Mouse button names use retail's
`DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right
mouse inputs keep the instruction dialog open and re-arm capture.
- Setting the chord already present on the same row is a no-op. New chords use
retail's dense two-slot insertion rule, and conflicts use priority dialogs
with the exact installed-DAT singular/plural and non-bindable text.
- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration,
and startup persistence are covered. Revert is enabled only while dirty;
OK avoids rewriting an unchanged file.
- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs,
PFile bracket-text grammar, filename normalization, overwrite/read-only
handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile
preference, startup load, and graceful-shutdown rewrite. The portable JSON
file remains only as an acdream-host-command compatibility mirror.
- Escape follows retail's priority: finish jump charge, release focused UI,
stop movement/repeat attack, cancel target mode, clear selection, then
toggle the authored Gameplay Options page. It never exits player mode or
exposes the orbit/developer camera. Shift+Escape reaches the normal logout
gate.
- Selection cycling applies retail's containment, cloaking, radar, attackable,
fellow, vendor, environment, combat-mode, and opened-corpse rules.
Opened-corpse history lives for the session and retires on object deletion.
- Screenshot, help, and plugin actions are consumed. Missing separately
shipped retail help/plugin surfaces report an honest chat/system result
rather than doing nothing.
## Automated verification
- App: 6,413/6,413 passed.
- Core: 4,713/4,713 passed.
- Runtime: 1,849/1,849 passed.
- UI.Abstractions: 879/879 passed.
- Installed-DAT identity/default conformance and the authored Configure
Keyboard mount pin all 306 rows.
The `.keymap` codec parses the committed real retail file and round-trips all
306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers,
DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48
CharacterOption action names. AP-202 is retired. The connected gate could not
be run because no local ACE endpoint was listening on UDP port 9000.
## Connected acceptance gate
Use the installed EoR DATs and the normal owner-gate pak. In Configure
Keyboard, verify that all rows are enabled and that a key, modifier-only chord,
and mouse button can each be rebound. Exercise representative movement,
camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and
character-option actions. Verify conflict prompt, Cancel, Revert, Defaults,
Apply, and OK, then restart the process and confirm the applied bindings remain.

View file

@ -1,68 +0,0 @@
# Issue #178 — retail EnvCell shell culling (2026-08-28)
## Outcome
The Phase A8 `CullMode.Landblock -> CullMode.None` workaround is removed
from both production EnvCell draw paths. Constructed cell-shell batches now
use retail's clockwise cull state instead of drawing every ordinary shell
face twice. No PAK rebuild is required: the package already contains the
authored `sides_type` and the correctly expanded index geometry; this change
selects the correct GPU state when that geometry is drawn.
The source and installed-DAT gates are complete. The owner visual gate passed
2026-08-28 (“Ok looks good”) at the requested interior matrix; #178 is closed.
## Retail oracle
The misleading detail is that `DatReaderWriter.Enums.CullMode` is used for
the CellStruct polygon's `sides_type`; its values are not direct GPU cull
states.
- `D3DPolyRender::ConstructMesh @ 0x0059DFA0` expands `sides_type` 0 as the
positive face, type 1 as that face plus a reversed copy, and type 2 as the
positive and negative surfaces. Its polygon fan is `[0, i-1, i]`; the
reversed copy is `[i, i-1, 0]`.
- `D3DPolyRender::RenderMeshSubset @ 0x0059CA10` draws the constructed mesh
with `D3DCULL_CW` on the ordinary path.
- `RenderDeviceD3D::DrawEnvCell @ 0x0059F170` uses that constructed-mesh
route. The immediate-mode exception for a type-1 polygon does not apply to
the EnvCell mesh.
`MeshExtractor.PrepareCellStructMeshData` already reproduces retail's fan and
the required reversed geometry. The render policy therefore must be
clockwise culling for every constructed shell batch; mapping DAT value 0 to
`None` was the obsolete workaround, while mapping it to the generic
`Landblock` render state would cull the opposite side.
## Installed-DAT catalog
`A8CellAudit cell-winding-catalog` scanned the installed DATs:
- 772 environments and 3,168 CellStructs
- 38,189 polygons and 70,091 generated fan triangles
- 37,843 `Landblock(0)` polygons and 346 `None(1)` polygons
- no unknown or unsupported `sides_type` values
- no missing polygon vertices
Vertex-normal orientation was recorded as a diagnostic, not treated as a
contract: CellStruct vertex normals may be smoothed rather than geometric,
and retail submits the identical authored fan.
## Implementation and gates
- `EnvCellRenderer.ResolveRetailCellShellCullMode` documents and enforces the
constructed-mesh policy in both the main and shadow-receiver draw paths.
- Hermetic extraction tests pin the exact type-0 fan and type-1 reversed-face
expansion.
- Renderer tests pin all four source enum values to the retail clockwise
constructed-mesh state.
- Installed-DAT audit: passed.
- Canonical Release gate: 16,321 passed, 0 skipped, 0 failed across 14 test
assemblies; Release build completed with 0 warnings and 0 errors.
## Owner visual acceptance
In Holtburg buildings and the Facility Hub, rotate the camera through walls,
floors, ceilings, ramps, and stairs from their ordinary playable sides.
Nothing should vanish at any camera angle. Acceptance of that matrix closes
#178. The owner accepted this matrix on 2026-08-28 (“Ok looks good”).

View file

@ -1,141 +0,0 @@
# Open-issue validity audit — 2026-08-28
## Scope and verdicts
This is a read-only product audit of every non-final entry in
`docs/ISSUES.md`. It does not fix product code and it does not close or
reprioritize anything. The ledger contains **88** such entries, not 87:
`#341` still says `OPEN` on its status line even though its heading and its
own evidence say it closed with a 10/10 gate.
- **CONFIRMED CURRENT** means the current source, a current missing path, or
repeated recorded evidence supports the material claim. This bucket also
identifies whether the item is a product defect, missing feature,
maintenance/performance debt, or test-infrastructure flake.
- **GHOST / NOT A CURRENT CLIENT ISSUE** means the entry is fixed, stale,
superseded, based on a deleted path, an accepted decision, an external
system/environment condition, or is a research/test-plan note misfiled as a
client issue. “Ghost” does not mean the original report was fabricated.
- **NEEDS VERIFICATION** means there was plausible historical evidence, but
current source inspection cannot prove the user-visible symptom still
exists. These should not be called either fixed or real until a focused
current-binary gate reproduces or clears them.
The audit checked the current source and tests, the evidence/status text in
the ledger, superseding issue/campaign records, and a clean current Release
build. The build completed with **0 warnings and 0 errors**.
## Confirmed current — 35
| ID | Type | Verification basis |
|---:|---|---|
| #178 | Closed after audit (owner-accepted 2026-08-28) | Named-retail confirms CellStruct `sides_type` controls constructed geometry and every constructed EnvCell subset uses clockwise culling. Both double-sided overrides are removed, the installed-DAT catalog found no invalid side types or missing vertices across 38,189 polygons, winding/policy tests plus the 16,321-test Release gate passed, and the owner accepted the Holtburg/Facility-Hub visual matrix. |
| #241 | Fixed after audit (2026-08-28) | The always-on parity partition now rejects off-frustum landblocks before walking entities and preserves the player's current landblock, using the prepared frame frustum already available at the call site. |
| #258 | Owner-voided 2026-08-28 | The former ImGui host is deleted, but the owner classified a replacement developer-panel host as undesired scope and closed the entry. |
| #261 | Telemetry defect | Production `LinkStatusSnapshot` still receives no packet-loss calculation, so `PacketLossPercentage` remains its default. |
| #310 | Fixed after audit (2026-08-28) | Collision-prefix retirement now supersedes an exact authored mover still awaiting first preparation, then completes through the normal withdrawal handshake; the former indefinite-poll regression converges. |
| #311 | Fixed after audit (2026-08-28) | `RetryPendingProjections` now uses retained, depth-safe scratch lists; the warmed fixture drops from 424 B to the event stream's 72 B publication floor and re-entry is regression-tested. |
| #313 | Fixed after audit (2026-08-28) | Successful split-to-world recovery now selects the resulting GUID through the canonical `SelectionState`; unrelated unknown spawns cannot steal selection. |
| #316 | Fixed after audit (2026-08-28) | The player-only `AirborneSnap` shadow skip is gone; every accepted landing publishes the resolved collision pose and the 27-row routing matrix passes. |
| #320 | Fixed after audit (2026-08-28; stale ledger entry) | The Runtime physics cutover already committed every ordinary transition's exact cell and rebucketed from `CellCommitted`; the added local-player test proves source-landblock retirement cannot park a player who walked across the boundary. |
| #322 | Fixed after audit (2026-08-28) | Both production callers now consume one pure disposition/HasAnims pre-placement derivation; the end-to-end application matrix and explicit truth table pass. |
| #324 | Architecture debt | Graphical and no-window hosts still own parallel inbound entity-routing composition. Accurate structural debt; no current symptom is proven. |
| #325 | Fixed after audit (2026-08-28) | Gate A and Runtime authority now accept equal-or-newer teleport stamps without consuming TELEPORT_TS; wrap, stale-pair, velocity, hook, and acknowledgement tests pass. |
| #330 | Headless capability defect | The headless composition still registers no live-entity collision owner, so bots cannot collide with dynamic creatures/objects. |
| #332 | Headless capability gap | Headless composition still lacks the graphical host's remote dead-reckoning path. Whether this is required product scope remains a decision. |
| #340 | Fixed before audit; stale ledger entry | `dfc841b7` injected a deterministic meter clock and the fixture uses a constant timestamp, removing suite load from the policy contract. |
| #346 | Fixed before audit; stale ledger entry | `dfc841b7` added tiered-JIT warmup plus five-batch sampling while retaining a threshold far below the former linear allocation regression. |
| #359 | Fixed after audit (2026-08-28) | The live route now passes the canonical player GUID and `ChatLog.OnPlayerKilled` suppresses victim/killer recipients exactly like retail; regression tests cover victim, killer, and bystander. |
| #360 | Fixed after audit (2026-08-28) | One shared Runtime dispatcher now implements the complete named-retail allegiance, house, and standalone `@motd` grammar for graphical and headless hosts; all GameAction payloads are byte-verified and the 16,309-test Release gate passes. |
| #361 | Fixed after audit (2026-08-28) | `@log` has a reconnect-safe file lifecycle; `@day` now toggles persistent noon landscape lighting; `@render radius/fov` implements the named-retail parsing, bounds, replies, and persisted renderer settings. |
| #370 | Headless movement defect | The released-jump probe reproduced 3/3 after the threading hypothesis was eliminated. No later fix is recorded. |
| #393 | Void/closed after audit (2026-08-28) | High-resolution DAT use is already implemented in both live and pak paths. The alleged separate retail toggle was a research misread; the remaining old texture-level degradation is explicitly unwanted by owner direction. |
| #400 | Fixed after audit (2026-08-28) | The exact authored two-root `gmCreditsUI` flow is mounted: 2,345 localized fragments, seven cyclic pictures, retail timing/shared scroll, Please Wait exit, and return to character management. Installed-DAT and controller gates pass. |
| #401 | Fixed after audit (2026-08-28) | Retained UI is now default-on across all launch paths, literal `ACDREAM_RETAIL_UI=0` opts out, the session-config force is removed, and authoritative launch docs/tests are updated. |
| #402 | Fixed before audit; stale ledger entry | `dfc841b7` moved the contract to a named dedicated thread with bounded start/block/join and captured worker failure, eliminating the fragile scheduling observation. |
| #403 | Fixed after audit (2026-08-28) | The live presenter now delegates advance/wrap and no-sequence interpolation to `RetailAnimationCyclePlayback`; Core and presenter regression suites pass. |
| #404 | Fixed after audit (2026-08-28) | Resolver now consumes `Runtime.CharacterCreation.Options`; the second raw SkillTable read and lock are gone, with focused, synthetic-projection, and installed-DAT gates passing. |
| #408 | Fixed after audit (2026-08-28) | The shared importer now applies DAT `0x3B` client-wide; all stateful retained widget types apply named/DirectState visibility, the scoped chargen path is deleted, and an installed-DAT sweep proves 990/990 built authored-invisible widgets across 38 layouts start hidden. |
| #410 | Fixed after audit (2026-08-28) | Both justification axes now share retail's exact 1/3/5 table, unauthored text defaults Left/Top, merge uses property presence, and automated plus installed-DAT UI suites pass. |
| #413 | Fixed after audit (2026-08-28) | All owned-house builders now match the recovered retail strings/math/colors; 0x0227/0x0228 refresh the shared Runtime owner, synthetic wire/state/UI coverage passes, and the 16,315-test Release gate is green. |
| #421 | Performance debt | The directional-shadow renderer still owns and uploads `DirectionalShadowTransformBufferSet` separately from the main instance SSBO. |
| #422 | Intermittent native crash | Heap corruption at graceful exit has multiple independent sightings, including a connected run. It lacks a stack, not evidence. |
| #423 | Rendering-policy mismatch | The atmospheric pack still declares and evaluates raw `ActiveDayGroupMultiplier` values instead of a `WeatherKind`-keyed policy. |
| #428 | Fixed after audit (2026-08-28) | Installed DATs proved the scripted sky carriers have one identity-transformed part 0; publishing that exact synthetic part lets legitimate particle hooks resolve, while the day-group-flip regression proves teardown prevents later dispatch. |
| #438 | Missing diagnostic feature | Launcher crash bundles are genuinely not implemented. This is an approved enhancement, not an existing launcher malfunction. |
| #442 | Fixed before audit; stale ledger entry | `d123c4b6` moved the warmed dense path to the shared 64-frame zero-allocation measurement probe; the strict zero allocation contract remains. |
## Ghost / not a current client issue — 33
| ID | Why it should not remain framed as a current client defect |
|---:|---|
| #3 | Fixed: periodic `TimeSync` is parsed and routed into the world clock. |
| #73 | A process policy for future string sweeps, not a concrete defect; its own text says no infrastructure work remains. |
| #180 | Both camera-collision fixes shipped and were log-verified; the residual visual was moved to now-closed #181. |
| #194 | Fixed: `WbDrawDispatcher.BeginFrame` prunes old instance groups and has coverage. |
| #195 | Obsolete architecture: the duplicate ChatVM/provider shape described by the issue no longer exists; local commands have dedicated routing. |
| #199 | Fixed by the Campaign CA server-authoritative one-request-in-flight raise flow; optimistic local mutation was removed. |
| #200 | Stale migration list: the old inline mounts/MockupDesktop path named by the issue no longer describes current composition. |
| #212 | Implementation and regression coverage are present; the status remained `IN-PROGRESS` only for an old user gate. |
| #213 | Fixed: client commands are intercepted by `ClientCommandController` instead of being sent to ACE as chat. |
| #228 | Directly disproved by the current Release build: 0 warnings, not 17. |
| #242 | Fixed: static presentation orders/prepares snapshots once and reuses the prepared replacement rather than rebuilding a third dictionary per attempt. |
| #249 | Obsolete: it targets the deleted OpenGL/bindless backend; production is Vulkan-only. |
| #251 | Obsolete: it targets deleted `glClientWaitSync`/OpenGL fence code. |
| #256 | Superseded by #260; its discriminator found no missing-object drift and the transport-loss mechanism was fixed elsewhere. |
| #257 | Superseded/refuted as a leak: its own portal-churn discriminator was flat/negative and later PAK/runtime work replaced the measured architecture. |
| #259 | Explicitly a machine-wide Vulkan/environment failure, not an acdream product defect. |
| #274 | A request for a connected retail comparison, not an observed failure. It belongs in a gate/research checklist. |
| #309 | Explicit owner-accepted divergence recorded in the divergence register, with no planned fix. |
| #318 | A test-plan/composition-coverage residual, not evidence of a product defect. Track as test debt if still desired. |
| #339 | Fixed and live-validated; current mesh-publication guards are present. The status header was never finalized. |
| #341 | Internally contradictory ghost: heading/evidence say closed and 10/10 bit-identical, but the status line still says `OPEN`. |
| #342 | Fixed: the current assertion compares old-model and new-model values rather than the same expression to itself. |
| #343 | Fixed; the issue body already records the guarded native-release lifecycle correction. |
| #344 | Fixed; the issue body already records the teleport-authority discriminator and clean suite. |
| #350 | Fixed in current source: the lifetime render-shadow counters are widened to `long`. |
| #352 | The behavior was live-gate verified; the only remaining request is one extra discriminating unit test. This is test debt, not a vendor bug. |
| #366 | Fixed: the chat controller now owns the unread indicator, unseen-text state, tick/click behavior, and tests. |
| #369 | An unanswered retail research question with no demonstrated mismatch, so it does not belong as a product issue. |
| #383 | Installed-DAT/committed-fixture provenance drift is environment/fixture-maintenance work, not a current client defect. |
| #384 | The client sends the swear action; the missing response is an ACE server behavior/blocker, not an acdream client bug. |
| #396 | Fixed and live-verified crash-free; only a visual re-check of the already-mounted instruction dialog remained. |
| #425 | Fixed; both resolution-scaled pack budgets and activation memo behavior are recorded in the issue itself. |
| #427 | Fixed and owner-reported; the sky/fog seam correction is already recorded in the issue itself. |
## Needs current verification — 20
| ID | What is known and what is still required |
|---:|---|
| #2 | The old lightning presentation mismatch was plausible, but the sky/PES pipeline changed substantially. Reproduce side-by-side on the current binary. |
| #29 | The thin-cloud observation has no current post-renderer visual gate. A present-day retail comparison is required. |
| #55 | The 1.45M `meshMissing` figure belongs to an old streaming diagnostic. Re-measure current PAK-v2 production before treating it as real. |
| #130 | Closed as owner-accepted residual (2026-08-28) | The connected visual re-gate confirmed the thin top-edge strip remains; owner direction is to leave it as-is. |
| #177 | Strong historical dungeon evidence exists, but the renderer/portal pipeline changed afterward. Repeat the named stair routes on the current binary. |
| #183 | Owner-closed 2026-08-28 after the validity audit. |
| #250 | The zero-allocation flakes were real historically, but the stated roughly-one-in-three frequency predates major runtime/test changes. Run a current parallel stress lane. |
| #262 | Owner reports the first-login movement issue solved; closed 2026-08-28. |
| #265 | Closed during follow-through: Campaign P's final user matrix already accepted downhill bounce, flat pop, and uphill landing on 2026-07-31. |
| #267 | Closed during follow-through: Campaign P's final user matrix already accepted live vitae/buff values and immediate skill-row refresh on 2026-07-31. |
| #317 | The call still exists, but “has no retail basis” is a research conclusion, not proof that current behavior is wrong or reachable. |
| #321 | One old full-suite sound-cache failure is not enough to establish a current flake after later suite/runtime changes. Stress the exact class in parallel. |
| #323 | The stale-receipt mechanism is plausible, but production reachability and a user-visible symptom were never established. |
| #377 | The fullscreen crash was once deterministic but is explicitly not reproducible on current display code. A current cold-start matrix decides it. |
| #397 | Closed during follow-through: the real Windows supervisor stopped a connected Release headless host gracefully, and a separately supervised process re-entered the same account after the documented 2.5-second ACE account-release quiescence; both exited code 0. |
| #431 | CA2CA4 implemented the missing inbound/recompute flow and the first CA5 drive passed Quickness/run updates. The original title is no longer proven; remaining CA5 cases need live gating. |
| #433 | Owner-observed stale entities are credible, but the issue is intermittent and has no current captured reproduction. |
| #439 | One full-suite failure followed by an isolated pass and clean rerun is only a flake candidate. Run the established timing lane before confirming it. |
| #441 | Owner-closed 2026-08-28 after two old observations were followed by a healthy probed baseline and no recurrence. |
| #452 | Closed during follow-through: both fixed `app-release24` sessions have retained logs proving graceful logout and orderly teardown after the 100-switch/30-minute stress. |
## Recommended ledger cleanup order
1. Close or archive the 33 ghost entries after owner review.
2. Keep type labels on confirmed items so
enhancements, maintenance debt, and flaky tests are not mistaken for
gameplay defects.
3. Run one focused verification batch for the 20 uncertain entries. Close a
fixed-awaiting-gate item when its current gate passes; close an old symptom
as stale when its documented current reproduction route no longer fails.
4. Prioritize only the confirmed product defects after the ledger is clean;
do not mix them with refactors, test debt, or external ACE/environment work.

Some files were not shown because too many files have changed in this diff Show more