Compare commits
No commits in common. "main" and "codex/release-stabilization" have entirely different histories.
main
...
codex/rele
|
|
@ -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)"
|
||||
}
|
||||
3
.github/workflows/copilot-setup-steps.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
.github/workflows/headless-portability.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
4
.github/workflows/hygiene-assessment.lock.yml
generated
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
.github/workflows/release-gate.yml
vendored
|
|
@ -1,6 +1,9 @@
|
|||
name: Complete Release gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
10
.gitignore
vendored
|
|
@ -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
|
|
@ -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 A1–A6 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 S1–S3
|
||||
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-215–AP-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 A–K 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, V0–V11 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 (~100–500 lines/sec). Pair with retail's cdb
|
||||
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
|
||||
protocol. Runtime-toggleable via the DebugPanel.
|
||||
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
||||
decisions at frame boundaries. Used to converge the U.4c flap fix
|
||||
(root indoor visibility at player's cell, not eye).
|
||||
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
|
||||
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
|
||||
per-armed-tick steer lines (signed gap dist, applied delta, heading
|
||||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
||||
#171 residuals (the deep-overlap sign pin AP-82).
|
||||
- `ACDREAM_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. |
|
||||
|
|
|
|||
15
AcDream.slnx
|
|
@ -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
|
|
@ -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, V0–V11 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 (~100–500 lines/sec). Pair with retail's cdb
|
||||
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
|
||||
protocol. Runtime-toggleable via the DebugPanel.
|
||||
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
|
||||
decisions at frame boundaries. Used to converge the U.4c flap fix
|
||||
(root indoor visibility at player's cell, not eye).
|
||||
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
|
||||
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
|
||||
per-armed-tick steer lines (signed gap dist, applied delta, heading
|
||||
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
|
||||
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
|
||||
#171 residuals (the deep-overlap sign pin AP-82).
|
||||
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
|
||||
collision geometry where the visual geometry is?** (#337, TEMPORARY).
|
||||
`[support]`: one line per resolve **for every body, not just the player**
|
||||
(a corpse falling through geometry is the cheapest control there is on
|
||||
"movement code vs geometry data"). It samples the outdoor terrain
|
||||
independently at the body's own out-XY and prints the contact plane's own
|
||||
height at that same XY, so `support=terrain` / `object` / `none` is a
|
||||
measurement rather than an inference; `cpSrc=` names the site that wrote
|
||||
the plane so provenance cross-checks the classification. Edge-eager,
|
||||
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
|
||||
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
|
||||
cloud against its visual mesh AABB in the same frame, with a verdict
|
||||
(`coincident` REFUTES "collision isn't where the visual is";
|
||||
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
|
||||
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
|
||||
those cases — it carries no plane normal, no plane height, no terrain
|
||||
sample and no provenance.
|
||||
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
|
||||
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
|
||||
beside the same objects' visual mesh boxes (magenta) and the terrain
|
||||
surface (yellow). Settles "visual versus collision" by eye instead of by
|
||||
log. `ACDREAM_WIRE_RADIUS=<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)
|
||||
|
||||
|
|
|
|||
19
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 699 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 819 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 86 KiB |
2985
docs/ISSUES.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 1–3; bake-tool 4
|
||||
appended typed GfxObj,
|
||||
**Slice I3 prepared collision extension (2026-07-25).** The package remains
|
||||
format 1 and retains mesh type values 1–3; 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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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<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 2–120 (`=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 |
|
||||
|
|
@ -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 5–10 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–4.** Mount `0x2100005B` ×4 as
|
||||
always-resident children per `gmGamePlayUI::SetupChildren
|
||||
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.35–0.80 ms |
|
||||
| 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.20–0.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.50–3.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.15–0.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 (0–6) 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 5–10 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.05–0.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.9–1.0 (mid-greys will
|
||||
brighten ~10 %) or lower **Filmic strength** to 0.7–0.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 (0–2×) and an **Off** setting. Transitions
|
||||
between day groups interpolate over the existing weather delta seconds
|
||||
(`uAtmosphereWeather.z`), so a weather change never snaps. Wind direction is
|
||||
a pack setting (default NE→SW, 225°); there is no authored retail wind
|
||||
direction to read, and the register row says so.
|
||||
|
||||
### Indoor, shadows, physics, picking
|
||||
|
||||
- Indoors (`!IsOutdoor`) wind is zero — EnvCells have no scenery anyway.
|
||||
- Shadow casters apply the identical displacement (shared include).
|
||||
- Physics is untouched by construction: the collision BSP is the trunk and
|
||||
nothing here touches Runtime or Core physics. The acceptance test asserts
|
||||
the Runtime/physics diff is empty.
|
||||
- World picking (`WorldPicker`) picks against the undisplaced mesh; a leaf
|
||||
may be up to `leanAmp + branchAmp` metres from its pick volume. That is
|
||||
accepted and registered (one row, "render-only foliage displacement");
|
||||
nobody picks leaves.
|
||||
|
||||
### Budget and quality scaling
|
||||
|
||||
- Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target:
|
||||
< 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix
|
||||
tooling (`tools/run-atmospheric-performance-matrix.ps1`).
|
||||
- Low preset: lean + branch only (no flutter). Medium/High: all three.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- Automated: the classification flag is set only for 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` (0–2×, default 1.0), `wind-direction-degrees`
|
||||
(0–360°, 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 49–65 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 4–5 (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 19–65 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.
|
||||
|
|
@ -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). |
|
||||
|
|
@ -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**.
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
# Campaign AS — assess/examination window retail parity (player targets)
|
||||
|
||||
**Status: CLOSED — CONNECTED GATE PASSED 2026-08-25 ("fixed! gate pass!").**
|
||||
AS1–AS5 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 G1–G10, and
|
||||
§5 rulings R1–R8 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 G1–G10; rulings R1–R8.
|
||||
- **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 3–7, 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 1–2 and 8–14: 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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
# MossTank — VTank parity campaign
|
||||
|
||||
Date: 2026-08-26
|
||||
Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 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 User1–User5;
|
||||
- [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.
|
||||
|
||||
MT7–MT9 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/User1–User5 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 | MT2–MT9 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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, 1–4 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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 1–4. Pack shaders
|
||||
must not redeclare or alias it. Set 3 is strictly opt-in: retail pipeline
|
||||
layouts contain only sets 0–2, 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 0–1.
|
||||
|
||||
`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 0–3 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, 1–4 |
|
||||
| `uShadowTextureAndFlags.z` | Square shadow-map resolution in pixels |
|
||||
| `uShadowTextureAndFlags.w` | Flags; bit 0 means directional shadows are valid/enabled; bits 8–11 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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–2 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 5–6 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 2–4 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.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
1–2 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`).
|
||||
|
|
@ -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`, 1–4), 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 §§1–2. 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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: 11–14 % of pixels at mean |Δ| ≈ 1–1.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.
|
||||
|
|
@ -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
|
||||
11–14 % of pixels at mean |Δ| ≈ 1–1.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.
|
||||
|
|
@ -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.7–0.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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.9–10.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.7–0.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).
|
||||
|
|
@ -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, 2–4 cascades, opaque + alpha-cutout casters, animated casters, terrain + world receivers. Extended to moon shadows (doc records owner approval 2026-08-22). |
|
||||
| Bloom, ACES tonemap, colour grade, vignette | **Delivered** | Gamma-space caveat (F4). |
|
||||
| Screen-space sun rays | **Delivered** | Sky-depth occlusion mask, 48-tap radial; sun-only. |
|
||||
| Volumetric shafts | **Delivered** | Reuses shadow depth; sun-only; no jitter (F8). |
|
||||
| Rays composited before tonemap | **Delivered** | |
|
||||
| Ray/shaft intensity from authored weather | **Delivered** | `AtmospherePolicyDeclaration` maps `activeDayGroup` + sun elevation in the pack descriptor. |
|
||||
| Opt-in, retail default authoritative, via plugin API | **Delivered** | BCL-only `AcDream.Plugin.Abstractions.Rendering`, SDK validator, three samples. |
|
||||
| #226 detail overlay | **Delivered, with caveats** | Buildings + EnvCells (correct per decomp), through the previously dead checkbox. See F2/F3. |
|
||||
| Terrain vertex normals (A2) | **Delivered, verified** | Retail `calc_lighting` 0x00531700 port. See F5d for the premise correction. |
|
||||
| Subdivision (A3) | Rejected | Agree. |
|
||||
| Tree wind / sway | **Not present** | Raised after codex's brief; not a gap in their work. Still the cheapest item on the list. |
|
||||
|
||||
---
|
||||
|
||||
## Findings, by severity
|
||||
|
||||
### F1 — HIGH (evidence): default-path invariance is unproven
|
||||
|
||||
> **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 10–50 m" as part of the retail contract. **Nothing in retail has
|
||||
a distance gate on detail:** `RenderDeviceD3D::DrawBuilding` (0x0059f2a0),
|
||||
`DrawEnvCell` (0x0059f170), `D3DPolyRender::RenderMeshSubset` (0x0059ca10) and
|
||||
`ACRender::SetDetailSurfaceInternal` (0x006b6280) install surface, tiling,
|
||||
WRAP, LINEAR×3 and the blend — no viewer-distance term. Retail's distance
|
||||
attenuation is mip averaging (LINEAR mip filter), which for the live category
|
||||
texture (mean factor 1.033) converges to near-neutral on its own.
|
||||
|
||||
This is a guessed AC-specific constant in a class named `Retail…Contract`,
|
||||
with TS-52 retired and no replacement register row — a double breach of the
|
||||
workflow rules. **Ask:** either drop the ramp and rely on mips (retail), or
|
||||
keep it and file the register row naming it an acdream adaptation.
|
||||
|
||||
### F3 — MEDIUM (premise): #226 ports retail's two-pass fallback; which path real hardware ran is undetermined
|
||||
|
||||
> **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.9–10.5 MiB/frame outdoor readings
|
||||
are automation observer cost". Pre-campaign production dense Caul measured
|
||||
3.0/4.9 ms CPU p50/p95. The A/B is valid for the *Medium-minus-retail
|
||||
increment* (~0.7–0.9 ms GPU); it says nothing about the owner's hitch or
|
||||
about production default-path cost.
|
||||
c. "14,928/14,928, zero skips" holds only under the hermetic lane filter. A
|
||||
raw `dotnet test AcDream.slnx` shows 74 skips and 36 failures across five
|
||||
assemblies; re-run in isolation with the repo filter, all five pass
|
||||
(7,560 / 0). Fine — but the headline should say "hermetic lanes".
|
||||
d. The Track A report repeats the findings doc's wrong premise that
|
||||
`TerrainUtils.GetNormal` produced "the faceted look". That function only
|
||||
orients scenery (`SceneryGenerator.cs:166`). The rendered mesh already had
|
||||
smooth central-difference normals (Phase 3b). The real change —
|
||||
central-difference → retail's split-aware incident-face average — is a
|
||||
genuine, decomp-verified parity port (0x00531700: sum unit plane normals
|
||||
per vertex id, normalise, `(0,0,1)` fallback), but a subtler visual change
|
||||
than either document implies. The reviewer owns the original error.
|
||||
|
||||
### F6 — LOW (architecture): the pack API is heavier than what it can express
|
||||
|
||||
42 public types in `AcDream.Plugin.Abstractions.Rendering`.
|
||||
`RenderPassSemantic` is the built-in pack's pass list (BloomDownsample,
|
||||
BloomBlur×2, SunOcclusion, SunRays, VolumetricShafts, FilmicComposite) plus
|
||||
`CustomFullscreen`. A third-party pack can swap shaders within the built-in
|
||||
pipeline and add fullscreen passes; it cannot introduce a new pass kind. That
|
||||
is a sensible v1 (Iris/OptiFine are fixed programs with named slots) — the
|
||||
docs should describe it that way rather than as a general framework. The
|
||||
SPIR-V validator living in the BCL-only abstractions assembly is unusual but
|
||||
lets the SDK validator run without App/Vulkan references. The built-in pack
|
||||
registers through `BufferedRenderPackRegistry.Register(descriptor, assets)`
|
||||
exactly like externals — good.
|
||||
|
||||
### F7 — LOW (perf/architecture): the shadow pass keeps its own transform buffer
|
||||
|
||||
`DirectionalShadowTransformBufferSet` uploads a second matrix buffer. The
|
||||
matrices are recomposed from the same `MeshRef.PartTransform × LocalToWorld`
|
||||
through the same `WbDrawDispatcher.ComposePartWorldMatrix`, so there is no
|
||||
second pose (constraint 5 honoured in spirit), but there is a second
|
||||
composition and upload per animated part per frame, and this buffer is where
|
||||
the 65,536-matrix ceiling bug lived. Measured cost is small (0.11–0.21 ms CPU
|
||||
incremental). A future GPU-culling step will want one shared buffer.
|
||||
|
||||
### F8 — LOW (quality)
|
||||
|
||||
- `atmospheric_volumetric.frag` marches up to 64 steps with no per-pixel
|
||||
jitter → visible banding at Low/Medium step counts.
|
||||
- The sun-occlusion mask is "depth ≈ far plane" only; no disc/sky-radiance
|
||||
weighting, so rays are uniform-coloured.
|
||||
- `SkyDescLoader.ResolveSortCenter` swallows exceptions to `Vector3.Zero`;
|
||||
acceptable for enhancement metadata, but note it against the silent-catch
|
||||
rule. The other 26 added `catch` sites are cleanup-then-rethrow or
|
||||
convert-to-failure-outcome, consistent with the fail-safe contract.
|
||||
|
||||
---
|
||||
|
||||
## What is good (specifically)
|
||||
|
||||
- **Receiver shader** (`directional_shadow_receiver.glsl`): bias in world
|
||||
metres (constant + slope-scaled + normal offset), scaled per cascade by
|
||||
texel density — the #129 NDC-bias trap is handled by construction; true
|
||||
compare-then-interpolate PCF via `textureGather`; cascade blend band and
|
||||
terminal reach fade in metres so FOV cannot move seams.
|
||||
- **Cascade fitter**: bounding-sphere per split, radius quantised to 1/16 m,
|
||||
centre snapped to the light-space texel grid — stable CSM done right.
|
||||
- **Caster pass**: per cascade, one terrain MDI plus the opaque/cutout MDI
|
||||
runs over the **full resident set**; no per-cascade CPU reculling, with
|
||||
classification counters so the gate is enforced rather than hoped.
|
||||
Alpha-cutout casters sample and discard. Low uses multiview.
|
||||
- **Indoor gating**: `IsOutdoor = RenderSky && !CameraInsideCell`.
|
||||
- **Lifecycle**: off-side candidate preparation, atomic activation, withdrawal
|
||||
at a frame boundary, no-retry per registration, Auto with 180-sample
|
||||
hysteresis and a visible reason string. Device loss is correctly treated as
|
||||
terminal to the device lifetime.
|
||||
- **Boundaries**: `git diff main..HEAD -- src/AcDream.Runtime src/*/Physics`
|
||||
is empty; Core edits are additive (`TranslucencyFadeManager.Revision`,
|
||||
`SkyObjectData.AuthoredSortCenter`); Headless references no pack types.
|
||||
- **#226 blend**: `src = (d.rgb·f, d.a·f)` under `DSTCOLOR + INVSRCALPHA` gives
|
||||
`dest × (1 + f·(d.rgb − d.a))` — exactly neutral at f=0, retail's measured
|
||||
factor at f=1. The dead `BuildingDetailTextures` checkbox is now live
|
||||
without a second option.
|
||||
- **A2**: the decomp claim is correct and the port matches it (unit face
|
||||
normals summed per shared vertex, per-landblock like retail).
|
||||
- **Tests**: green under the repo's hermetic filter — 7,560/0 in the five
|
||||
assemblies re-run in isolation, plus Runtime 1,818, Core 4,877,
|
||||
UI.Abstractions 884, Content 160, MossTank 48, RenderPackValidator 30.
|
||||
|
||||
---
|
||||
|
||||
## Performance summary
|
||||
|
||||
Reference adapter (RX 9070 XT, 1080p capped, fixed dense 9,498-caster scene):
|
||||
Low 0.108 ms CPU incr / 0.90 ms GPU incl / 40 MiB; Medium 0.116 / 1.00 / 74;
|
||||
High 0.121 / 1.24 / 114; volumetric +0.19 ms GPU. Credible and inside the
|
||||
declared budgets. Caveats: one high-end adapter; the integrated-AMD row proves
|
||||
only safe fallback; the connected dense-town figures carry the observer tax
|
||||
(F5b); the default path's own pre/post cost is unmeasured (F1).
|
||||
|
||||
---
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. F1 — run both checks (pixel gate vs `6c79d35c`; clean production A/B).
|
||||
Blocking for merge.
|
||||
2. F2 — decide: drop the ramp (retail) or register it. Small.
|
||||
3. F3 — one cdb read on the retail client. Small; also settles the
|
||||
reviewer's earlier finding.
|
||||
4. F4 — linear-light post stack as a follow-up slice.
|
||||
5. F5 — correct the four sentences in the plan/reports.
|
||||
6. Owner visual gate per the plan's own final-gate list; then ship.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 88–91 % pixel differences that were **all
|
||||
configuration, none renderer** — recorded so nobody repeats them:
|
||||
|
||||
1. **Hidden-window captures vs old-tool captures differed by a 0.952 zoom.**
|
||||
HEAD's gate writes an isolated `settings.json` with no `fieldOfView`, so
|
||||
HEAD rendered at the default 90° gameFOV while the old binary read the real
|
||||
file's `86.33°`. Not a renderer change. Consequence: captures from the new
|
||||
`run-offline-pixel-gate.ps1` and from the pre-campaign tool are **not
|
||||
comparable** to each other; each tool is self-consistent.
|
||||
2. **Connected HEAD frames were brighter with tree shadows.** The real Roaming
|
||||
`settings.json` still held `renderPack = acdream.atmospheric/low` from the
|
||||
owner's live gate — the pack was ON. (The reviewer had been reading and
|
||||
editing a stale `%LOCALAPPDATA%\acdream\settings.json`; the client's
|
||||
Windows config root is `%APPDATA%\acdream`.) Every HEAD run must pin the
|
||||
selection explicitly.
|
||||
3. **A minimized HEAD window rendered brighter than hidden/visible.** Windows
|
||||
throttles an iconified GLFW surface; the 12 s settle had not completed.
|
||||
Minimized is not a product condition and is not used.
|
||||
|
||||
## Pixel results
|
||||
|
||||
Exact per-pixel comparison (`max |ΔRGB|`), no tolerance.
|
||||
|
||||
**Open field (logout spot, 60.7S 89.1W, few dynamics):**
|
||||
|
||||
| Pair | px differing | notes |
|
||||
|---|---|---|
|
||||
| base vs base+normals (A2 only) | 12.7 %, mean Δ 1.5 | terrain shading, low amplitude |
|
||||
| **base+normals vs HEAD-off (KEY)** | **4.3 %, mean Δ 9** | all of it: idle-pose outline, regenerating mana digits, one ambient flyer crossing the camera — ground, trees and UI clean (`diff-I: HEAD-off vs base+normals (KEY).png`) |
|
||||
| HEAD-off vs HEAD-on (detail) | 3.3 % | no buildings in view; same noise class |
|
||||
|
||||
**Holtburg (`/telepoi Holtburg`, buildings, NPCs, animated lifestone):**
|
||||
the same binary twice differs in 25–35 % of pixels by ±1–7 (sun/time
|
||||
sub-steps), so a raw count is meaningless. Pixels with |Δ| ≥ 8 between two
|
||||
logins of the *same* binary define the dynamic mask (lifestone, NPCs, particle
|
||||
emitter, radar blips, digits; 9.8 % of the frame after a 6 px dilation). In
|
||||
the remaining 90.2 %:
|
||||
|
||||
| Pair | |Δ| ≥ 8 px in static region | max |
|
||||
|---|---|---|
|
||||
| base+normals self (two logins) | **0** | 7 |
|
||||
| HEAD-off self (two logins) | **0** | 7 |
|
||||
| **base+normals vs HEAD-off (KEY, run 1)** | **841 (0.10 %)** | 128 |
|
||||
| **base+normals vs HEAD-off (KEY, run 2)** | **729 (0.09 %)** | 128 |
|
||||
| base vs base+normals (A2 only) | 7,884 (0.95 %) | 212 |
|
||||
| HEAD-off vs HEAD-on (detail) | 513 (0.06 %) | 255 |
|
||||
|
||||
The 841/729 KEY pixels are streaks inside the animated lifestone crystal that
|
||||
escaped the mask dilation (`evidence/vm0/holtburg-KEY-static-strong-diff.png`);
|
||||
buildings, roofs, ground, trees, sky and every UI element are clean. The A2
|
||||
row is the expected shading change on terrain. The detail row is below the
|
||||
threshold on building shells because the fallback blend's mean factor is
|
||||
1.033 (+3 %); VM1's single-pass re-port (−10 %) will make it measurable.
|
||||
|
||||
Evidence: `docs/research/evidence/vm0/*.png`, full captures under
|
||||
`artifacts/vm0/` (not committed).
|
||||
|
||||
## Production performance (no observer)
|
||||
|
||||
`tools/vm0/perf-run.sh`: connected, **uncapped Release**, no
|
||||
`ACDREAM_AUTOMATION_ARTIFACT_DIR` (so no render-scene observer),
|
||||
`ACDREAM_FRAME_PROF=1`, same isolated config, 75 s at each spot; last six
|
||||
5-second `[frame-prof]` windows (`evidence/vm0/perf-frame-prof.txt`):
|
||||
|
||||
| Spot | Binary | CPU p50 / p95 / p99 | GPU p50 / p95 | alloc KB/frame | Gen0 per 5 s |
|
||||
|---|---|---|---|---|---|
|
||||
| Holtburg | base | 4.7 / 5.1 / 5.5 ms | 0.4 / 0.5 | 574 | 12–13 |
|
||||
| Holtburg | **HEAD off** | **4.1 / 4.4 / 4.8 ms** | 0.4 / 0.5 | **21** | 0–1 |
|
||||
| Arwic (dense) | base | 6.0 / 6.5 / 7.0 ms | 0.7 / 0.8 | 582 | 10 |
|
||||
| Arwic (dense) | **HEAD off** | **5.2 / 5.6 / 6.1 ms** | 0.7 / 0.9 | **29** | 0–1 |
|
||||
|
||||
No regression. The pack-off path is ~13 % cheaper on CPU with ~25× less
|
||||
managed allocation per frame; GPU unchanged. This also settles review F5b:
|
||||
the "27.8 ms retail-path CPU" in the Stage-2 connected report was the
|
||||
automation observer, not the product.
|
||||
|
||||
## Verdict
|
||||
|
||||
VM0 PASSES. Campaign AR's default path is the pre-campaign renderer plus the
|
||||
two declared parity changes. The F1 blocker is cleared.
|
||||
|
||||
## Carried forward
|
||||
|
||||
- The new pixel gate's isolated settings should carry `fieldOfView` (and
|
||||
`gamma`) so its captures mean the same as the product's — filed for VM7's
|
||||
tool tidy; not a renderer issue.
|
||||
- `run-offline-pixel-gate.ps1` gained `-BuildingDetailTextures` (VM0).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 450–700 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 7–8, sprites
|
||||
`0x01001A61..63` = single ±137.5 m quads whose 64×64 additive textures are
|
||||
soft glow blobs (row/col profiled — no banding; peak RGB 36–71/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.03–0.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.
|
||||
|
|
@ -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 0–180), the hillside trees (rows 300–360) and the
|
||||
shoreline bushes (rows 420–480). 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.
|
||||
|
|
@ -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 5–10 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.
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
# Campaign CA — connected gate script (CA5, user-driven)
|
||||
|
||||
**Purpose:** live verification of the whole advancement chain against ACE
|
||||
after CA1–CA4 (`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 1–2 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?).
|
||||
|
|
@ -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 CT2–CT6 (`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 111–113 ("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 **1–4**
|
||||
(`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 3–4 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 CT2–CT6 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.
|
||||
|
|
@ -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: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 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 (value−9999) 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 4–6 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.
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
# Campaign AS — connected gate script (AS6, user-driven)
|
||||
|
||||
**Purpose:** live verification of the assess/examination-window parity
|
||||
campaign (AS2–AS5) 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 372–1000, 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)?
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
# Campaign CT — connected gate script (CT7, user-driven)
|
||||
|
||||
**Purpose:** live verification of the character-panel parity campaign
|
||||
(CT1–CT6) 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?
|
||||
|
|
@ -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 A–C and E–F 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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, User1–User5 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.
|
||||
|
|
@ -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 1–4 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.
|
||||
|
|
@ -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 1–12 |
|
||||
| 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 10–18 |
|
||||
| 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.
|
||||
|
|
@ -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”).
|
||||
|
|
@ -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 | CA2–CA4 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.
|
||||