Compare commits

..

No commits in common. "main" and "claude/git-sync-status-5fb1d2" have entirely different histories.

2296 changed files with 32265 additions and 750008 deletions

View file

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

View file

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

View file

@ -1,6 +1,48 @@
name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "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.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/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "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.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/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:
@ -18,13 +60,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
dotnet-version: "10.0.x"
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Bake, Plugin.Abstractions, Core,
# below is presentation-free: it builds Plugin.Abstractions, Core,
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
# the Headless CLI. Nothing in it opens a display, links GL, or calls
# xvfb-run, so an "install the graphical smoke dependencies" step here was
@ -36,9 +78,6 @@ jobs:
shell: pwsh
run: |
$projects = @(
"src/AcDream.Platform/AcDream.Platform.csproj",
"src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj",
"src/AcDream.Bake/AcDream.Bake.csproj",
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
@ -59,9 +98,6 @@ jobs:
shell: pwsh
run: |
$projects = @(
"tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj",
"tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj",
"tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj",
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
@ -81,89 +117,6 @@ jobs:
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify Linux headless host executable permission
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless
portable-launcher:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Build and test the portable launcher
shell: pwsh
run: |
dotnet build src/AcDream.Launcher/AcDream.Launcher.csproj -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Publish the self-contained launcher distribution
shell: pwsh
run: |
$rid = if ($IsWindows) { "win-x64" } else { "linux-x64" }
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
-c Release `
-r $rid `
-o "artifacts/acdream-launcher-$rid"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify self-contained Windows launcher and bake artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$root = "artifacts/acdream-launcher-win-x64"
if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" }
if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" }
if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" }
if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" }
$env:DOTNET_ROOT = "Z:\definitely-not-installed"
$env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed"
$env:DOTNET_MULTILEVEL_LOOKUP = "0"
& "$root/acdream-launcher.exe" --verify-publish
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$root/acdream-bake.exe" --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify self-contained Linux launcher and bake artifacts
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-launcher-linux-x64
self_contained=$(dotnet msbuild \
src/AcDream.Launcher/AcDream.Launcher.csproj \
-nologo \
-property:RuntimeIdentifier=linux-x64 \
-getProperty:SelfContained | tr -d '\r\n ')
test "$self_contained" = true
test -x "$root/acdream-launcher"
test -x "$root/acdream-bake"
test ! -f "$root/acdream-launcher.dll"
test ! -f "$root/acdream-bake.dll"
DOTNET_ROOT=/definitely-not-installed \
DOTNET_ROOT_X64=/definitely-not-installed \
DOTNET_MULTILEVEL_LOOKUP=0 \
"$root/acdream-launcher" --verify-publish
DOTNET_ROOT=/definitely-not-installed \
DOTNET_ROOT_X64=/definitely-not-installed \
DOTNET_MULTILEVEL_LOOKUP=0 \
"$root/acdream-bake" --help
linux-graphical:
runs-on: ubuntu-latest
@ -171,10 +124,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
dotnet-version: "10.0.x"
- name: Build and publish Linux graphical client
shell: pwsh
@ -264,10 +217,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
dotnet-version: "10.0.x"
- name: Install lavapipe, the Vulkan loader and Xvfb
shell: bash

View file

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

View file

@ -1,35 +0,0 @@
name: Complete Release gate
on:
workflow_dispatch:
permissions:
contents: read
jobs:
complete-release:
name: Complete Release suite (Windows)
runs-on: windows-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Run complete bounded Release gate
shell: pwsh
run: ./tools/run-release-gate.ps1
- name: Upload Release gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: release-gate-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
retention-days: 14
path: artifacts/release-gate/

10
.gitignore vendored
View file

@ -2,11 +2,6 @@
bin/
obj/
out/
# NOTE: the repo-root /bin folder holds the alpha distribution feed written by
# tools/publish-bin.ps1. It stays IGNORED here on purpose so a stray `git add`
# can never put ~150 MB of payloads on main (GitHub also hard-rejects any file
# over 100 MB). tools/publish-dist.ps1 force-adds it onto the Gitea-only `dist`
# branch instead, which is what the launcher's update feed reads.
# Rider / VS
.idea/
@ -113,8 +108,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/

View file

@ -45,14 +45,13 @@ in `src/AcDream.*` references it as a project dependency.
`TextureCache`, `GlobalMeshBuffer`, shader infrastructure, and the
EnvCell/portal/scenery/terrain-blending pipeline classes.
**Modern rendering path is MANDATORY.** The N.5 ship amendment deleted
`WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`;
Campaign V (`docs/plans/2026-07-27-vulkan-campaign.md`, closed
2026-07-29) then ported the renderer to Vulkan behind the RHI contract
and deleted the OpenGL backend outright — `AcDream.App` references only
`Silk.NET.Vulkan`. There is no legacy fallback. Engineering cribs
(WbMeshAdapter seams, N.5 SSBO layout, translucency model, gotchas)
live in `memory/reference_modern_rendering_pipeline.md`.
**Modern rendering path is MANDATORY** as of the N.5 ship amendment.
`WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
are deleted. Missing `GL_ARB_bindless_texture` or
`GL_ARB_shader_draw_parameters` throws `NotSupportedException` at
startup. There is no legacy fallback. Engineering cribs (WbMeshAdapter
seams, N.5 SSBO layout, translucency model, gotchas) live in
`memory/reference_modern_rendering_pipeline.md`.
Before re-implementing any AC-specific rendering or dat-handling
algorithm, **read `docs/architecture/worldbuilder-inventory.md` FIRST**.
@ -79,20 +78,17 @@ and `~/.claude/projects/.../memory/` (the latter is browsable in
Obsidian via the `claude-memory/` junction in the repo root; see
`memory/reference_obsidian_vault.md`).
**UI strategy:** one presentation stack — the retained retail
`UiHost`/`UiRoot` tree in `AcDream.App/UI`, imported from LayoutDesc/DAT
assets and bound by focused controllers over shared state, ViewModels,
events, and commands (the ViewModels/commands seam from the earlier
two-stack era remains the stable boundary between state and
presentation). The ImGui.NET developer-tools frontend
(`AcDream.UI.ImGui`) and the OpenGL backend it required were deleted at
Campaign V slice V11 (`docs/plans/2026-07-27-vulkan-campaign.md`);
`ACDREAM_DEVTOOLS=1` now only selects the optional Vulkan
validation/debug-utils extensions (see the flag's log line in
`Program.cs`). `TextRenderer` + `BitmapFont` serve D.6 world-space HUD
elements in the 3D scene. Plugin gameplay UI uses the BCL-only
**UI strategy:** two coexisting presentation stacks over shared state,
ViewModels, events, and commands. ImGui.NET +
`Silk.NET.OpenGL.Extensions.ImGui` is the permanent
`ACDREAM_DEVTOOLS=1` developer stack using `IPanel`/`IPanelRenderer`.
Retail gameplay UI is the independent retained `UiHost`/`UiRoot` tree in
`AcDream.App/UI`, imported from LayoutDesc/DAT assets and bound by focused
controllers. The stable cross-stack seam is ViewModels/commands, not a backend
swap. `TextRenderer` + `BitmapFont` also serve D.6 world-space HUD elements
where ImGui cannot reach the 3D scene. Plugin gameplay UI uses the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel` contract; plugins
never import App namespaces. Full design:
never import App or ImGui namespaces. Full design:
[`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md).
Memory cribs: `claude-memory/project_chat_pipeline.md` (chat pipeline as of
Phase I), `claude-memory/project_input_pipeline.md` (input pipeline as of

View file

@ -7,39 +7,13 @@
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
<Project Path="src/AcDream.Launcher/AcDream.Launcher.csproj" />
<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" />
<Project Path="tools/WeatherSetupProbe/WeatherSetupProbe.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
@ -50,17 +24,6 @@
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj" />
<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>

329
CLAUDE.md
View file

@ -43,14 +43,13 @@ in `src/AcDream.*` references it as a project dependency.
`TextureCache`, `GlobalMeshBuffer`, shader infrastructure, and the
EnvCell/portal/scenery/terrain-blending pipeline classes.
**Modern rendering path is MANDATORY.** The N.5 ship amendment deleted
`WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`;
Campaign V (`docs/plans/2026-07-27-vulkan-campaign.md`, closed
2026-07-29) then ported the renderer to Vulkan behind the RHI contract
and deleted the OpenGL backend outright — `AcDream.App` references only
`Silk.NET.Vulkan`. There is no legacy fallback. Engineering cribs
(WbMeshAdapter seams, N.5 SSBO layout, translucency model, gotchas)
live in `memory/reference_modern_rendering_pipeline.md`.
**Modern rendering path is MANDATORY** as of the N.5 ship amendment.
`WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
are deleted. Missing `GL_ARB_bindless_texture` or
`GL_ARB_shader_draw_parameters` throws `NotSupportedException` at
startup. There is no legacy fallback. Engineering cribs (WbMeshAdapter
seams, N.5 SSBO layout, translucency model, gotchas) live in
`memory/reference_modern_rendering_pipeline.md`.
Before re-implementing any AC-specific rendering or dat-handling
algorithm, **read `docs/architecture/worldbuilder-inventory.md` FIRST**.
@ -77,20 +76,17 @@ and `~/.claude/projects/.../memory/` (the latter is browsable in
Obsidian via the `claude-memory/` junction in the repo root; see
`memory/reference_obsidian_vault.md`).
**UI strategy:** one presentation stack — the retained retail
`UiHost`/`UiRoot` tree in `AcDream.App/UI`, imported from LayoutDesc/DAT
assets and bound by focused controllers over shared state, ViewModels,
events, and commands (the ViewModels/commands seam from the earlier
two-stack era remains the stable boundary between state and
presentation). The ImGui.NET developer-tools frontend
(`AcDream.UI.ImGui`) and the OpenGL backend it required were deleted at
Campaign V slice V11 (`docs/plans/2026-07-27-vulkan-campaign.md`);
`ACDREAM_DEVTOOLS=1` now only selects the optional Vulkan
validation/debug-utils extensions (see the flag's log line in
`Program.cs`). `TextRenderer` + `BitmapFont` serve D.6 world-space HUD
elements in the 3D scene. Plugin gameplay UI uses the BCL-only
**UI strategy:** two coexisting presentation stacks over shared state,
ViewModels, events, and commands. ImGui.NET +
`Silk.NET.OpenGL.Extensions.ImGui` is the permanent
`ACDREAM_DEVTOOLS=1` developer stack using `IPanel`/`IPanelRenderer`.
Retail gameplay UI is the independent retained `UiHost`/`UiRoot` tree in
`AcDream.App/UI`, imported from LayoutDesc/DAT assets and bound by focused
controllers. The stable cross-stack seam is ViewModels/commands, not a backend
swap. `TextRenderer` + `BitmapFont` also serve D.6 world-space HUD elements
where ImGui cannot reach the 3D scene. Plugin gameplay UI uses the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel` contract; plugins
never import App namespaces. Full design:
never import App or ImGui namespaces. Full design:
[`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md).
Memory cribs: `claude-memory/project_chat_pipeline.md` (chat pipeline as of
Phase I), `claude-memory/project_input_pipeline.md` (input pipeline as of
@ -130,194 +126,8 @@ user-accepted, including exact response flags, independent examination
window, inscription transaction, complete creature/item/spell reports,
favorite-spell press/right-click behavior, modern scarab/prismatic formula,
DAT component icons, foreground stacking, and authored 310 x 400 extent.
Slice 4 equipped-child world picking passed its two-client Coldeve gate and
was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor
experience — browse, staged buying, selling, walk-to-use, the authored
panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE
(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed
two latent client-wide crashers (#348 cursor-handle exhaustion, #350
render-ledger overflow).** **Campaign P — physics retail-feel parity
(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31
— final user matrix accepted.** Every physics-scope gap from the
2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800
sentinel — ACE's >=800 is a misread; never re-import), the #265/#166
landing-momentum + bounce family
(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae
panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide
— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod
parse). See the plan doc for the retired-row ledger. **Campaign A — audio
retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is
CODE-COMPLETE 2026-08-08** with slices A1A6 landed and listening-gate
rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and
the formal plan-status flip. **Campaign CH — chat & interface-text retail
parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED
USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail
colors, the SpewBox with retail's two-plane glyph outlines, working side
channels, the 152-verb command registry, the CH6 window shell (floating
windows, all-corner resize, opacity), and verbatim /help. Carried tail:
#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1S3
polish items. **Campaign OP — the retail four-tab Options panel
(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE
2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat /
Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are
acdream's ONE in-client settings surface (design D1): F11/toolbar open the
authored tab host; `RuntimeCharacterOptionsState` + the 53-id
`CharacterOptionTable` own option storage; retail's wire split ships exactly
(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1`
PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`);
headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED);
OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the
`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/
combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix
batch (#372 both halves, #374, #375, #378#382, #385) is USER-PASSED; the
OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif
row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT
tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4,
OS-localized fallback, register AD-96; the `InitiateBinding` capture-
instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`,
live-verified no-crash). **STILL OWED: the full §OP3§OP6 script sections
and §OP8's visual re-check** — script
`docs/research/2026-08-11-campaign-op-test-script.md`, launch with
`ACDREAM_RETAIL_UI=1`. Tail:
#371, #373, AP-198/199/201/202/203. START at
`claude-memory/project_settings_options_digest.md`.
**Campaign FA — the retail social panel (Fellowship & Allegiance)
(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is
CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social
panel (Friends / Allegiance / Fellowship / Squelch, host slot
`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only —
Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel
recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire
(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR
tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and
the truncated XP-share table), two session-scoped Runtime owners
(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at
generation reset — D2 corrected), and the authored panels through
`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners.
**The fellowship two-session flow is PROVEN over the live wire** — FA6's
automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/
`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips
`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round →
narrow re-review; the reviews caught what tests can't (retail's 4th tree
rule, the D2 reset-lifetime inversion, the D6 server-side invite filter,
a seam-map entry that would have re-introduced a fixed bug). OWED: the
user's connected gates (§FA3-§FA6 of
`docs/research/2026-08-12-campaign-fa-test-script.md`, several
`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is
deferred/disabled because ACE returns NOTHING to the `0x001D` swear at
0.005 m (no confirmation, no tree update, no error; needs ACE-console
disambiguation — the swear CODE is done+reviewed, only its automated
two-session proof is unverified; register AD-87). Tail: #383 (installed-
DAT vs committed-fixture drift, found at FA3). START at
`claude-memory/project_fellowship_allegiance_campaign.md`.
**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all
USER-PASSED.** The social panel's connected gate rounds closed (border-only
move cursor, amber row selection, wrapped empty-state text, composed
confirmation sentences via the new `DatStringResolver.ResolveTemplate`
StringTable-interleave port, the refused-drop SpewBox notice via the
`InventoryTransactionState.RequestFailed` seam, live friends
Online/Offline through the authored row state machine + the new UiText
per-state string swap). Same block: powerbar mode captions
(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by
combat mode), release-edge airborne jump refusal (supersedes CH round-1's
press-edge report), and **SECURE TRADE SHIPPED + two-client user gate
PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`),
full `0x1F6``0x208` wire, `RuntimeTradeState` as the third sibling
J-owner, both retail open paths, staged-item trading marker
(`ClientObject.TradeState` now live), cancel text. START at
`claude-memory/project_secure_trade.md`; the deferred-Func lesson is
`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register:
AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired.
Filed: #393 (texture-detail options, post-M4).
**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia
launcher/installer/updater (Windows+Linux) + the retail character-
management screen, driven autonomously under a user-set goal: Fable
plans, Sonnet implements, Opus dual-lens reviews (architectural +
retail-faithful). Spec:
`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan +
ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at
`claude-memory/project_launcher_direction.md`. Key recon corrections
already binding: retail's select screen (`gmCharacterManagementUI`) has
NO 3D preview (chargen-only machinery); UI Studio no longer exists
(deleted at Campaign V — ignore stale memory/docs claims otherwise);
App `Program.cs` has no subcommand dispatch (the `--session-config` flag
is additive).
LA0 through LA11's automated scope are review-closed. The launcher composer is now
compiled into both host test suites, and Launcher.Core runs in the portable
Windows/Ubuntu CI closure. The self-contained Avalonia launcher,
transactional two-host plugin lifetime, shared login-command route,
Runtime-owned retail selection state, authored DAT character screen, and
crash-safe verified installer plus atomic cross-platform updater/self-updater
are integrated. Windows group-isolated Headless stop, isolated update fixtures,
strict status/redaction evidence, and the exact Windows/Ubuntu operator script
are landed; the integrated preflight passes 32/32 commands and 14,012 tests /
5 skips. Only the connected/visual/real-DAT user gate remains before shipment.
**Campaign CC — retail character creation (CLOSED USER-ACCEPTED
2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as
one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches
A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and
PASSED. **Milestone: the first live character ever created by acdream
against ACE landed mid-round.** The gate round's own harvest hardened
shared surfaces well beyond chargen: authored text margins (P0x23-26),
the authored Unselected/Selected state pair + per-state label color,
un-consumed Type-12 media children (frames/scrollbars client-wide),
single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the
dialog-always-on-top re-raise (the invisible-modal input blackhole), a
truthful client crash self-report + bounded stderr capture (#405-#407
fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create
button (retail's exact `UpdateButtons` roster<slots ghost gate)
`gmCharGenMainUI`'s six-page flow (Heritage / Profession / Skills /
Appearance with live 3D preview / Town / Summary with its own zoomed-out
viewport) → byte-exact 0xF656 with the 55-slot invariant → complete
0xF643 handling (roster append + retail log-straight-in; every rejection
dialog, incl. the corrected ground truth that retail shows NameDBDown
for Pending/Undef — the plan's original "retail swallows it" was
DISPROVEN at CC5's review) → the §LA1 `characterCreated`/`creationFailed`
launcher status cycle. `RandomizeCharacter` + sub-primitives are ported
(retail's ctor-time open-roll incl. the gender-flip quirk; humans-only
random heritage ids 1-4 — a real retail quirk). Plan + ledger:
`docs/plans/2026-08-15-character-creation-campaign.md`; connected gate
script: `docs/research/2026-08-16-campaign-cc-test-script.md` (launch:
launcher flow, or `ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`);
START at `claude-memory/project_character_creation_campaign_handoff.md`.
Register churn: AP-214/AP-225/TS-82/AD-101 retired; AP-211 updated;
AP-212 narrowed; AP-215AP-229 filed (AP-221 one-shot preview binding,
AP-222 spin-highlight no-op, AP-229 stacked-screens-vs-retail-teardown
are the ones a gate tester will meet). Known-flake set now also names
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
(full-solution parallel load only). Suites at `2176ba76`: full solution
14,426 / 4 skips, App 5257/3, Runtime 1735/0, Launcher.Core 324/0.
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
placement route now runs through the canonical residence + continuation-
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
(`6dc7ba51`), 6 drops (`1b484937`, zero production lines), 5 projectile
(`36255af0`), 7 child-cell propagation (`cd3129e9`), 3 portal
(`e0f96a55`), plus the `OnPosition` dual-tail collapse (`edc911b0`) that
retired the duplication behind three separate defects. Suite 11,027 →
**11,090 passed / 4 skipped / 0 failed**. Connected gates: routes 3, 6 and 7
user-passed 2026-08-05 with probe evidence; route 7's is THIN (one
`cause=propagate`) and 4b-3's `cause=cellless` case remains unrun with an
UNESTABLISHED trigger — route 7 invalidated its recorded recipe.
**C5 COMPLETE — the placement campaign is FULLY CLOSED (`addb5657`,
2026-08-07).** C5a deleted the legacy resolver outright and retired
AP-1/AP-145 (closing #318); C5b closed #275 and filed AP-147/AP-148; C5c's
closeout passed its 11,196-test automated gate and the owed connected-gate
batch USER-PASSED 2026-08-07. #280's portal-prefetch fix and its dual
review also landed (AP-149/150/151), and AP-22 retired 2026-08-06. Start
any new placement work at `claude-memory/project_placement_cutover_closed.md`
(probes deliberately NOT stripped; start at #331).
**Read `docs/research/2026-08-05-c4-closeout-handoff.md` before any
placement work.** Its seven process findings remain binding. The two that
cost the most that campaign: a contract asserting a mechanism that does not
exist caused three separate defects, and inferring a fact you can observe
made one fix strictly worse than the bug it replaced — it removed the
invariant failure while leaving the bug.
Resume at Slice 4 equipped-child world picking, then vendor browse and
authoritative transactions.
**Modern Runtime/performance status:** Slices AK of
`docs/plans/2026-07-24-modern-runtime-architecture.md` are complete. Slice L is
@ -750,10 +560,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,7 +574,6 @@ Documentation entry point: [`docs/README.md`](docs/README.md).
For canonical state, read in this order:
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
@ -1555,44 +1363,67 @@ governed by whether the previous shutdown was graceful or forced.
### Test character
`+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has
basic stats. Run/jump skills arrive FROM the server and drive local motion
prediction (`LiveMovementStatsApplier``PlayerMovementController`); the
hardcoded fallbacks before the server speaks are 200 run / 300 jump. The
former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no
longer exist — see the Retired section of
[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim
mismatch between local and observer views, check the server sync path
(`UpdateMotion.ForwardSpeed` echo via
`PlayerMovementController.ApplyServerRunRate`, or
`PlayerDescription (0x0013)`).
basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default
200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate`
for local motion prediction. **These are NOT synced to the server**
ACE's own character data is authoritative for broadcast motion. If you
see a speed/anim mismatch between local and observer views, the fix is
to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired
via `PlayerMovementController.ApplyServerRunRate`) or from
`PlayerDescription (0x0013)`.
### Diagnostic env vars
Every environment variable and command-line argument the client reads —
what it does, its exact value shape, and **what else it changes about the
run** — is documented in
[`docs/launch-options.md`](docs/launch-options.md). That file is the single
source of truth for every probe we have and how to turn one on, and it is
enforced by `LaunchOptionsDocumentationTests`: a flag without a documented
row fails the build, and so does a documented row whose read site was
deleted. **Any future probe that stays in the code gets its row there in
the same commit — no exceptions.**
The binding rules:
- **Every probe and dump is OFF by default.** Nothing that prints, records,
or costs performance may activate without its env var explicitly set
(`=1`). The only default-on flags are retail *behaviors* wearing an
A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`,
`ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES``=0`
disables); that set is frozen by `LaunchOptionsDocumentationTests`
never add a default-on diagnostic.
- **Read the side-effects column before any measurement.** Flags that look
inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame
diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a
streaming window production never uses.
- **A temporary probe dies with its investigation.** Add the row when you
add the probe; delete both in the commit that fixes the issue.
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
animation debugging.
- `ACDREAM_STREAM_RADIUS=N` — tune landblock visible-window radius
(default 2 = 5×5).
- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver-
broken setups.
- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per
`PhysicsEngine.ResolveWithTransition` call: input + target + output
position/cell, ok-vs-partial, grounded-in, contact-plane status,
wall normal if hit, **responsible entity guid**, env flag, walkable
polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via
the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per
`PlayerMovementController.CellId` change: old → new cell, world
position, reason tag (`resolver` / `teleport`). Low volume — only
fires on actual cell crossings. Runtime-toggleable via the same
DebugPanel section.
- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics
tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call),
`[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch),
`[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit).
Heavy under motion (~100500 lines/sec). Pair with retail's cdb
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
protocol. Runtime-toggleable via the DebugPanel.
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
decisions at frame boundaries. Used to converge the U.4c flap fix
(root indoor visibility at player's cell, not eye).
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
per-armed-tick steer lines (signed gap dist, applied delta, heading
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND
after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag
— NPC / remote DR calls don't pollute. Pairs with the trajectory
replay harness comparison tests to diff captured vs harness state
per field — the first divergence pinpoints missing apparatus state.
Capture is OFF when the env var is unset (one null-check cost per
call).
- `ACDREAM_DUMP_CELLS=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — dump
resolved cell/GfxObj polygon tables as JSON when ids cache. Used
for harness fixture extraction.
### Outbound motion wire format (acdream → ACE)

View file

@ -1,20 +0,0 @@
<Project>
<PropertyGroup>
<!-- Repository-wide language and warning policy. Project files only override
these values when a target has a documented, target-specific need. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AnalysisLevel>latest</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<!-- Use custom names for every graph. A conventional packages.lock.json
always overrides NuGetLockFilePath, which makes neutral and RID locks
impossible to keep side by side. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<NuGetLockFilePath Condition="'$(RuntimeIdentifier)' == ''">$(MSBuildProjectDirectory)/packages.neutral.lock.json</NuGetLockFilePath>
<NuGetLockFilePath Condition="'$(RuntimeIdentifier)' != ''">$(MSBuildProjectDirectory)/packages.$(RuntimeIdentifier).lock.json</NuGetLockFilePath>
</PropertyGroup>
</Project>

View file

@ -1,38 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Arch" Version="2.1.0" />
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="BCnEncoder.Net" Version="2.2.1" />
<PackageVersion Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
<PackageVersion Include="Chorizite.Core" Version="0.0.18" />
<PackageVersion Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Serilog" Version="4.0.2" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
<PackageVersion Include="Silk.NET.OpenAL.Extensions.Creative" Version="2.23.0" />
<PackageVersion Include="Silk.NET.OpenAL.Extensions.EXT" Version="2.23.0" />
<PackageVersion Include="Silk.NET.OpenAL.Soft.Native" Version="1.23.1" />
<PackageVersion Include="Silk.NET.Shaderc" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="StbImageSharp" Version="2.30.16" />
<PackageVersion Include="StbTrueTypeSharp" Version="1.26.12" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<fallbackPackageFolders>
<clear />
</fallbackPackageFolders>
</configuration>

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because it is too large Load diff

View file

@ -82,16 +82,6 @@ document in the same change; do not leave both claims standing.
- [`superpowers/specs/`](superpowers/specs/) and
[`superpowers/plans/`](superpowers/plans/) are per-slice design and execution
records. Completed plans remain historical.
- [`ci-and-releases.md`](ci-and-releases.md) is the SSOT for the Gitea CI
pipeline, the self-hosted runners, and how alpha releases are published.
Load-sensitive tests live in `Lane=Timing`; see
[`release-gate.md`](release-gate.md) before adding to it.
- [`launch-options.md`](launch-options.md) is the SSOT for every environment
variable and command-line argument the client reads, including what each one
changes about the run beyond its obvious effect. Read the side-effects column
before trusting any measurement. Enforced by
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
so does a row whose read site was deleted.
- [`audit/`](audit/) contains completion and conformance audits.
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
ACE server's complete in-game command catalog and points to the authoritative

View file

@ -92,7 +92,7 @@ stack. Full history and the corrected contract live in
│ LayoutDesc/DAT → UiRoot retained widgets + controllers │
├─────────────────────────────────────────────────────────────┤
│ SHARED CONTRACTS │
│ ViewModels, input actions, state/event and command seams
│ ViewModels, commands, input actions, state/event services
│ ► one model and mutation path, one presentation projection │
├─────────────────────────────────────────────────────────────┤
│ Game state + events (unchanged) │
@ -100,44 +100,27 @@ stack. Full history and the corrected contract live in
└─────────────────────────────────────────────────────────────┘
```
`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract and the
ViewModels — **survives intact**. It was always
`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract, the
ViewModels and the commands **survives intact**. It was always
backend-agnostic, which is exactly what Code Structure Rule 3 was written to
protect, and it is what a future developer-panel host would bind to. Only the
ImGui *backend* was deleted. `ACDREAM_DEVTOOLS=1` still selects Vulkan's
debug-utils extensions and now logs that the developer UI is gone; replacing it
is issue **#258**, deliberately unscheduled.
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, input, and the
`IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, commands, input,
and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
retained gameplay tree, LayoutDesc importer, window runtime, and panel
controllers. Neither presentation stack owns independent game-state truth.
Chat submission follows the same rule: `AcDream.Runtime/Chat` owns the shared
parser, retail command/channel/help catalogs, `ChatCommandRouter`, command bus,
and its four backend-neutral records (`ExecuteClientCommandCmd`,
`SendServerCommandCmd`, `SendChatCmd`, and `SendRawChannelCmd`). Its only
presentation callback is the four-member `IChatCommandFeedback`; retained
`ChatVM` implements that seam. Both App and Headless bind the same
`LiveChatCommandRoute` to the active `WorldSession` send delegates and exact
`RuntimeCommunicationState`/`RuntimeCharacterState` children. Panels never
inspect or construct wire messages, and Runtime has no UI or App dependency.
Configured login commands enter that identical parser/router only after the
generation's `enteredWorld` edge, in order, once per generation. The shared
generation-aware sequence cancels on replacement, applies the configured
inter-command delay, and reports each isolated failure without aborting the
session or plugin lifetime.
Chat submission follows the same rule: both presentation stacks enter the
shared `ChatCommandRouter`, which emits distinct backend-neutral intents for a
retail client command (`ExecuteClientCommandCmd`), an ACE-owned command
(`SendServerCommandCmd`), or ordinary chat (`SendChatCmd`). App-layer
handlers and controllers translate those intents to `WorldSession`; panels
never inspect or construct wire messages.
Plugins register retained gameplay markup through the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge:
the graphical host supplies its retained registry, while no-window hosts
return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin
binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator
and the same config allow-list semantics (absent loads all; explicit empty
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.
Core `SelectionState` is the sole selected-object owner for world,
presentation assemblies. 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.
Temporary pointer modes are separate App orchestration in `InteractionState` and
@ -165,16 +148,6 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the
retained tick/draw/restore/dispose paths. Panel-specific construction must not
move back into `GameWindow.OnLoad`.
The graphical no-selector launch projects Runtime's sole
`RuntimeCharacterSelectionState` through the retained character-management root
resolved from DAT enum table 5 (`0x10000005` -> `0x21000004`, selected root
`0x1000039A`). App borrows the view and routes generation-capturing typed
commands; it owns no roster, highlight, operation, error, or lifecycle mirror.
The authored screen is a flat ListBox and buttons, with the shared retail dialog
catalog for confirmation, wait, and error presentation. It contains no viewport
or character preview. Explicit-selector graphical launches and no-window hosts
do not mount this presentation.
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
desired/enchantment state projection; Core.Net owns exact manifest and live
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
@ -201,9 +174,6 @@ parallel window-lifecycle map.
```
src/
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
Plugins/
PluginSession.cs -> shared per-host allow-list, failure isolation,
status outcome, and collectible ALC lifetime
Physics/
PhysicsBody.cs -> body state / integration foundation (done)
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
@ -249,29 +219,14 @@ src/
generation + teardown
Session/ -> J2 canonical session lifetime, ordered
inbound routing + retryable teardown
RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world
roster/highlight/delete/restore/error owner;
borrowed view + ordered deltas + typed commands
Entities/
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
RuntimeEntityRecord.cs -> presentation-free accepted entity state
RuntimeEntityObjectLifetime.cs -> one entity/object lifetime root
RuntimeEntityObjectEventStream.cs -> canonical ordered entity/object deltas
RuntimeEntityObjectViews.cs -> direct allocation-free borrowed views
InboundPhysicsStateController.cs -> retail timestamp/snapshot authority,
including gate-only dormant acceptance
ParentAttachmentState.cs -> generation-exact parent relations plus raw
missing-parent Create admission
RuntimeInitialCreateAdmissionFreezer.cs -> immutable parser-payload copy
boundary for dormant initial placement
RuntimeInitialCreateResidenceState.cs -> exact-incarnation initial
placement lease and accepted mixed-update FIFO
RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent
adoption + retail Create tail + strict-order
FIFO/replay execution over the residence
Chat/ -> LA6 parser/router/catalog and four command
intents; shared live route + generation-scoped
configured-login sequence for both hosts
InboundPhysicsStateController.cs -> retail timestamp/snapshot authority
ParentAttachmentState.cs -> generation-exact parent relations
Gameplay/
RuntimeCommunicationState.cs -> one chat/social owner + ordered stream
RuntimeInventoryState.cs -> exact object-table borrower + inventory
@ -286,15 +241,6 @@ src/
Physics/
RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows,
collision receipts, bodies/hosts/worksets
RuntimeCollisionReportingState.cs -> exact-key retail collision table,
environment latch, ordered callbacks, and
SetPosition report-result ownership
RuntimeSetPositionState.cs -> exact placement/lost-cell operations,
authored mover retention, ordered host
receipts, and collision-generation wake
RuntimePlacementProjectionChannel.cs -> generation-gated public host
observation/retry/exact-ack seam over the
one Runtime SetPosition receipt owner
RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation
RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation
RuntimeProjectile.cs -> canonical projectile component/prediction owner
@ -302,83 +248,19 @@ src/
World/
RuntimeWorldEnvironmentState.cs -> canonical calendar/time/weather owner
RuntimeWorldTransitState.cs -> canonical reveal generation/readiness owner
Platform/
ApplicationPathSet.cs -> shared BCL-only XDG/Windows config, data,
cache, plugin, screenshot, and diagnostic paths
RuntimeGenerationReset.cs -> one retryable canonical-generation reset
-> Slice J complete; graphical and no-window hosts share one GameRuntime
-> may reference Core, Core.Net, Content, Plugin.Abstractions, and
Platform only
-> may reference Core, Core.Net, Content, and Plugin.Abstractions only
-> must never reference App, UI, Silk.NET, OpenAL, or Arch
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
plugin, screenshot, and diagnostic paths
BakePublicationGuardPaths.cs
-> shared launcher/Bake environment nonce and
adjacent publication lock/token naming contract
-> zero project/package references (guarded by
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
Runtime and App reference it directly; Headless reaches it
transitively through Runtime (K0 guard: Headless declares exactly
one project reference)
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
Profiles/ -> sole credential/profile document + CRUD owner
Launching/ -> config composition and supervised process seams;
Windows console hosts are no-shell, redirected-
stdin process-group leaders receiving targeted
CTRL_BREAK, while Linux hosts receive SIGINT
Status/ -> incremental host-status parsing/tailing
Orchestration/ -> immutable UI snapshots, typed actions,
capability gates, and running-session lifetime
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; one
OS-handle lease serializes recovery/install per
DataDirectory; a second OS-held publication
lock plus durable per-transaction nonce makes
late orphan Bake children irrevocably stale
before recovery, while already-authorized
promotion completes before recovery; only exact
adjacent
`.<pak>.acdream-bake.<guid:N>.tmp` files are
transaction-owned crash residue
Updates/ -> pinned GitHub manifest + strict SemVer/RID
authority, bounded verified streaming download,
hardened ZIP extraction, immutable
`app/<version>/` installs, atomic `current.json`
activation/rollback, and durable next-start
launcher self-update journal; one OS-handle
shared-session/exclusive-update barrier spans
every launcher process
-> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
Startup/Program -> one immutable process-local option graph before
owner construction; config/data/cache require
three absolute normalized roots and one exact
`ApplicationPathSet` reaches profiles, installer,
versions/updater, sessions, cache, orchestration
-> manifest override reaches only update composition,
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 and
nonfatal startup/manual update state, actions,
progress, cancellation, rollback, and errors
-> references Launcher.Core only (Platform transitively); it never owns
a second profile, process, status, or credential state graph
-> every per-RID publish composes the separately published self-contained
`acdream-bake` executable beside the launcher without a project edge
-> Linux launcher/probe/headless flows remain portable; graphical-client
actions are explicitly disabled until Modern Runtime Slice L resumes
AcDream.Headless/ Linux/Windows no-window production host
Program.cs -> CLI entry only
Configuration/ -> strict versioned process/session config
Credentials/ -> redacted env/stdin/owner-only-file providers
Hosting/ -> one GameRuntime/session/lease/policy lifetime
Plugins/ -> no-window IPluginHost borrowing Runtime/Core;
BCL no-op UI and per-session plugin lifetime
Policies/ -> typed Runtime-view/command consumers
-> references Runtime only; no presentation/backend package
-> Slice K complete: portable single/multi-session production host,
@ -389,7 +271,6 @@ src/
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs -> done
IPluginHost.cs -> done
IUiRegistry.cs -> capability-aware retained/no-op UI contract
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
@ -412,7 +293,9 @@ src/
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration
LiveEntityOrdinaryPhysicsUpdater.cs -> manager-less body Transition commits
RemoteInboundMotionDispatcher.cs -> animation-optional retail UM funnel
RemoteTeleportHook.cs -> ordered retail teleport teardown seam (teleport_hook port; C4 route 4b-3 runs it from LiveEntityNetworkUpdateController's teleport arm dispatch, through RuntimeRemotePlacementDriveController)
RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner
RemoteTeleportHook.cs -> ordered retail teleport teardown seam
RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit
World/
LiveEntityRuntime.cs -> exact-key App projection/lifecycle host
LiveEntityProjectionStore.cs -> materialized sidecars by RuntimeEntityKey
@ -444,16 +327,8 @@ src/
PlayerMovementController.cs -> active movement driver
Plugins/
AppPluginHost.cs -> done
GraphicalPluginSession.cs -> thin shared-session/root/status adapter
```
The 4B2 production SetPosition routes and shared local-controller body remain
dormant. Runtime now owns the exact collision table, environment latch, and
report-result semantics needed by that cutover. Activation still waits for
exact authored mover preparation, presentation-only rebucketing,
placement-prefix quiescence, and an atomic Runtime body/controller publication
transaction to land as one reviewed cutover.
---
## Movement And Collision Architecture
@ -566,26 +441,30 @@ What exists and is active:
and every routed hook. A delete/local-ID reuse during capture or during an
earlier hook can never advance the displaced sequencer or send the old
owner's remaining sound, particle, or light hooks to its replacement.
- **C4 route 4b-3 (2026-08-04) deleted `RemoteTeleportController` /
`RemoteTeleportPlacement` / `RemoteShadowPlacementSynchronizer` outright** —
605 + 85 + 49 lines of App-layer incarnation-scoped placement machinery,
replaced by routing the remote teleport/cell-less classification through
the SAME canonical `RuntimeRemotePlacementDriveController` the far snap
(C4 route 4b-2) already uses (`ApplyAcceptedRemoteTeleport`, sharing
`StoresAcceptedDestination`/`StoreAcceptedDestinationPose`). `teleport_hook`
(@0x00514ED0) still runs first, via `RemoteTeleportHook` invoked from
`LiveEntityNetworkUpdateController`'s teleport-arm dispatch (both the
player-guid and NPC-guid branches share one `RunRemoteArmTail` helper for
the routing-decision/currency/constraint-arm sequence — see the C4 4b-3
fix round, 2026-08-04, for why the two branches were unified there after
independently drifting). There is no separate "loaded vs pending
destination" placement path anymore: an unresolved destination collision
generation retains a preparation-stage retry inside the SAME drive
controller that far snap already retries through, not a second incarnation-
scoped machine, and `_activePlacementOwners`'s Hidden/UnHide visibility-edge
protection is gone with its only writer chain — the synchronous, single-
frame teleport commit removes the multi-frame window that protection
existed for.
- `RemoteTeleportController` owns the placement half of a fresh remote
teleport after `RemoteTeleportHook` has torn down movement/target state. It
collision-seats loaded destinations through `RemoteTeleportPlacement`; an
unloaded destination retains one generation- and PositionSequence-scoped
pending placement and resolves the latest accepted frame when that same
projection becomes visible. It neither owns GUID identity nor reconstructs
an entity. A placement failure after hydration restores the captured source
frame/cell/contact rather than leaving a visible collisionless projection;
source-resident shadows restore immediately, while an unloaded source
delegates one incarnation-scoped restore to
`LiveEntityPresentationController`, shared with Hidden/UnHide. A newer
placement transfers that marker into an explicit active-placement generation
before rebucketing even while Hidden. That generation suppresses every
intervening Hidden/UnHide and projection restore until the controller reaches
a stable result, then it either restores after collision seating, re-defers
its rollback source, or hands a Hidden result back for UnHide. The typed
`ILiveEntityRemotePlacementRuntime` seam permits a same-incarnation wrapper
rebind only around the canonical body; pending placement adopts that wrapper
before hydration and cannot silently lose ownership. Clearing a motion or
projectile component retains the incarnation's body/contract identity until
logical teardown. The production wrapper exposes an immutable body, while
hydration defensively validates arbitrary implementations against the record
and rolls the retained body back on mismatch. Runtime binding snapshots the
interface Body getter once for validation, assignment, and state mutation.
`GpuWorldState`
rebuckets atomically and commits spatial visibility before draining its
transition FIFO; `LiveEntityRuntime` rejects delayed duplicate edges. A
@ -606,60 +485,8 @@ What exists and is active:
collision assets loaded from the validated prepared package. Production
retains no parsed DAT collision graph; graph construction is restricted to
bake/equivalence tools and explicit test oracles.
- Landblock collision activation is generation-owned by
`RuntimePhysicsState`. Graphical and no-window hosts populate a private
`PreparedLandblockCollisionGeneration` over bounded cursors; its cache,
`CellGraph`, engine landblock, buildings, static shadows, and retained-owner
refloods are never visible through the borrowed live engine. Retained owners
comprise every non-suspended dynamic or adjacent-root static touching the
target prefix (including a withdrawn repair marker); target-root statics come
from the authored replacement. Runtime mutation-gates their exact capture,
refreshes each through the host work meter, and builds every cache/graph/
shadow replacement through one-work-unit seal cursors. Stable per-prefix
owner slots replace the former registry-global mutation gate. One Runtime-
scoped versioned journal coalesces repeated live mutations by owner instead
of copying the owner into every draft on every event. Each draft reconciles
only the latest exact state for owners changed during its lifetime, one owner
per seal step, so unrelated or continuously moving owners cannot restart the
target cursors. Once discovered, a relevant owner receives exact subscribed
updates without restoring global fanout. First entry to or departure from a
target after the global slot cursor has passed is routed through the owner's
changed prefix to that one matching draft. During topology construction a
visited unrelated owner receives only a cheap coalesced dirty notification;
its exact mirror is deferred to one metered seal unit. Once the topology seal
exists, observed owners temporarily write through exactly until activation,
so the finite pre-seal queue drains even when several unrelated owners move
continuously. Production activates in that same update-thread call. Slots older than a
newer draft's captured root are superseded at the tail rather than reused
behind live cursors; new drafts start at their captured suffix, obsolete
slots compact incrementally, and the journal clears with the last draft.
Empty prefix containers are reclaimed under GUID churn; seal cursors retain
their captured slot lists.
Cache, CellGraph, engine, and shadow topology share one complete off-side
`CollisionWorldState`. Admission captures the current root reference in O(1)
and materializes the non-target leaves through the same one-work-unit frame
meter; a dense resident world is never cloned synchronously. After an older
preparation commits, its exact landblock delta queues into every later draft
and drains one cache, graph, landblock, or owner leaf per seal step. A later
demotion or withdrawal cancels matching queued/active rebases and tombstones
that prefix in unfinished source scans, then retires one owner/cache/graph/
outdoor leaf per seal step from growable retirement storage. Commit rechecks
both retirement and rebase state after sealing, so retired topology cannot
return or cause a drafts-times-world-size update spike.
The host immediately performs the zero-work root transfer in the same update-
thread call that completes final reconciliation, so continuous unrelated
movement cannot manufacture a required quiet frame between seal and commit.
Deterministic preparation order prevents a later draft from exposing early,
inheriting cancelled topology, or overwriting a committed prefix. Final activation is one
zero-allocation volatile root transfer that preserves public facade identity,
revokes staging, and then emits `CollisionGenerationCommitted`. Cancellation disposes only the named
staging generation and never withdraws the previous active world. Thus
readers see the complete old generation or complete new generation, never a
mixed cell/cache/shadow world.
- `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby
objects and buildings. Streaming reflood is structurally part of the Runtime
collision-generation commit; there is no independent post-publication
reflood suffix.
- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and
buildings.
- `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain
Z" descriptions are historical B.3 language, not current architecture.
@ -696,37 +523,6 @@ server-GUID/incarnation map, Runtime local-ID allocation/reverse lookup,
accepted snapshots and timestamp gates, parent state, session/operation
versions, and exact tombstones. `RuntimeEntityRecord` is presentation-free.
Initial world placement has one deliberate dormant exception to ordinary
snapshot publication. While an exact `RuntimeInitialCreateResidenceState`
lease is waiting for its first canonical placement, retail timestamp gates may
accept later same-incarnation Create, ObjDesc, Parent, Pickup, Position,
Movement, State, and Vector packets, but neither the canonical record, public
accepted snapshot, event stream, nor presentation changes. Runtime retains
deep-frozen typed actions in one monotonic arrival-ordered FIFO under the exact
entity key. A Create whose parent is not yet addressable is retained even
earlier as a complete raw packet, before child timestamp admission, and is
guarded by a non-reused admission token. Delete, generation replacement,
reset, GUID reuse, and reentrant teardown discard only the matching ownership.
The admission checkpoint (`30012361`) intentionally stopped before executing
the FIFO. The continuation executor (`5db3de3c`,
`RuntimeInitialCreateContinuationExecutor`) completes the mechanism: one
synchronous, retry-idempotent `Execute` transaction adopts the acknowledged
initial placement exactly once, emits the local player's after-enter-world
hook request, replays deferred missing-parent raw Creates and queued parent
relations by parent GUID (whole-bucket detach, FIFO dispatch,
cancellation-aware restore windows), and drains the mixed FIFO strictly by
sequence — classifying each retained Position at execution time with live
inputs and driving authored placements through the canonical
`RuntimeSetPositionState` lifecycle with retryable yields. Apply bodies are
shared with the legacy fused inbound paths through gate-less instance seams
that keep the one snapshot store in lockstep; same-incarnation Create
envelopes apply atomically with buffered publication; every abandonment path
retires the residence and converges the combined ownership ledger. The
executor has NO production caller yet — graphical and no-window Create still
use legacy `RegisterEntity` — and the next checkpoint must switch both
production routes onto this owner rather than create another snapshot or
placement path.
`LiveEntityRuntime` is the App projection/lifecycle host.
`RegisterLiveEntity` first creates or refreshes canonical Runtime state without
an App record. `MaterializeLiveEntity` claims the Runtime local ID and creates

View file

@ -120,14 +120,6 @@ ViewModel or command had to change, because none of them had ever imported
writes against `IPanelRenderer`; a renderer implementation translates those
calls at runtime. Plugin-facing UI follows the same rule.
The shared chat parser/router/catalog and its four command intents live in
`AcDream.Runtime/Chat`, not in a panel or App. `AcDream.UI.Abstractions`
references Runtime so retained `ChatVM` can implement the narrow
`IChatCommandFeedback` seam and its existing panel input can call the shared
router. That dependency does not permit panels to import App, windowing,
rendering, audio, or another presentation backend; Runtime itself remains
presentation-independent and its dependency guards enforce that boundary.
**Status:** there is currently no `IPanelRenderer` implementation in the tree —
the ImGui one went with V11 and the replacement is issue **#258**. The contract
is kept rather than deleted precisely because this rule proved its worth; a new
@ -216,12 +208,8 @@ documentation, not an exhaustive allowlist):
- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for
the optional developer UI.
- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview ->
IRuntimeSettingsPreviewSource -> RuntimeSettingsController` for the settings
snapshot read before world drawing. (The `SettingsVM` draft-preview tail of
this seam was retired at Campaign OP slice OP9 — the preview source now
mirrors the committed Display/Audio snapshot directly; the retail Options
panel applies its edits live through `SaveDisplay`/`SaveAudio` instead of a
draft layer.)
IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional
SettingsVM` for the live settings draft preview applied before world drawing.
- `LocalPlayerPortalViewport -> LocalPlayerTeleportController ->
GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the
canonical portal/input lifetime and the host's input-action subscription.
@ -274,7 +262,9 @@ src/AcDream.App/
│ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
│ └── RemoteTeleportHook.cs # ordered retail teleport teardown actions (teleport_hook port; C4 4b-3 deleted RemoteTeleportController/RemoteTeleportPlacement/RemoteShadowPlacementSynchronizer — the teleport arm now routes through RuntimeRemotePlacementDriveController, the same canonical placement owner the far snap uses)
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
├── World/
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
@ -427,19 +417,28 @@ radar/status targeting, effects, and audio remain closed until reveal. This
distinction is part of the Slice E connected gate, not an alternate live-entity
lifetime.
**C4 route 4b-3 (2026-08-04) deleted `Physics/RemoteTeleportController` and
`RemoteTeleportPlacement` outright** (605 + 85 lines, plus
`RemoteShadowPlacementSynchronizer`, 49 lines). Remote teleport placement is
no longer a separate App-layer incarnation-scoped machine — it is the SAME
canonical `RuntimeRemotePlacementDriveController` route 4b-2's far snap
already uses, dispatched via `ApplyAcceptedRemoteTeleport`, which shares
`StoresAcceptedDestination`/`StoreAcceptedDestinationPose` unchanged.
`teleport_hook` (@0x00514ED0) still runs first — `RemoteTeleportHook`,
invoked from `LiveEntityNetworkUpdateController`'s teleport-arm dispatch —
and its `report_collision_end(this, 1)` action now routes through
`RuntimeCollisionReportingState.LeaveWorld` (the existing, exact port of
that retail call) rather than `ShadowObjectRegistry.Suspend`, which ports a
DIFFERENT retail function `teleport_hook` never calls. `GpuWorldState`
Remote teleport placement is bounded in `Physics/RemoteTeleportController`,
not `GameWindow`: it retains at most one pending request per materialized
incarnation, scopes it by the live generation and accepted PositionSequence,
and asks `RemoteTeleportPlacement` to collision-seat the current body when the
destination projection is available. `GameWindow` supplies lifecycle and
shadow-sync callbacks only; canonical identity remains in
`RuntimeEntityDirectory`, and App placement retains the exact projection key.
Failed hydration restores the captured source and delegates an
incarnation-scoped shadow restore to `LiveEntityPresentationController` while
that source is unloaded, so Hidden/UnHide and teleport never become competing
restore owners. A newer placement transfers that restore into an explicit
generation-scoped active-placement state before its rebucket visibility edge
even while Hidden. All intervening Hidden/UnHide and projection edges defer to
that owner until stable success or rollback completes; only then can it restore,
re-defer the source, or hand a Hidden result back for UnHide. The
`ILiveEntityRemotePlacementRuntime` seam keeps the complete cell/contact
handoff available across same-body runtime-wrapper replacement; replacing the
canonical body or dropping the placement contract within one incarnation is
rejected even after an operational component clear. `RemoteMotion.Body` is
constructor-owned; hydration compares pending/current wrappers directly to the
record body rather than trusting wrapper-to-wrapper equality. Binding reads an
interface Body getter once and reuses that snapshot. `GpuWorldState`
performs remove+place as one spatial rebucket,
then commits and serially drains visibility edges; `LiveEntityRuntime` filters
delayed duplicates. A rollback inside an observer cannot race the outer
@ -475,7 +474,7 @@ useful ordering seam, but its ownership status is **partial**.
| Area | Status | Current truth |
|---|---|---|
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
| Network session | **Complete Runtime ownership** | `LiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/pre-world selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Its `RuntimeCharacterSelectionState` owns the full active roster (including greyed entries and retained wire slots), highlight, delete confirmation, restore/delete/error state, generation/lifecycle, borrowed view, ordered deltas, and typed commands. A selector-free graphical launch pauses on that owner; explicit and headless selection retain the established fallback. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and borrowed projections—no mirrored session, selection state, or reset plan. |
| Network session | **Complete Runtime ownership** | `RuntimeLiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and one borrowed inertable UI command projection—no mirrored session or reset plan (`75930787`). |
| World environment | **J6.1 complete Runtime ownership** | `RuntimeWorldEnvironmentState` owns the instance-scoped Dereth calendar, synchronized clock, weather progression/state, selected day group, AdminEnvirons state, and typed debug overrides. App converts immutable DAT sky definitions once and projects the borrowed Runtime snapshot into rendering; no process-global Region origin or second App clock/weather owner remains (`902076c0`). TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. |
| Live identity/lifetime | **J3 complete** | `RuntimeEntityObjectLifetime` owns the sole `RuntimeEntityDirectory`, live `ClientObjectTable`, direct views, and ordered entity/object stream. The directory owns canonical GUID/incarnation/local-ID identity, accepted snapshots/timestamps, parent state, operation versions, and tombstones. `LiveEntityProjectionStore` owns App graphical sidecars by exact `RuntimeEntityKey`; hydration, presentation components, `GpuWorldState` residence/visibility, and retryable teardown preserve that key without another authority. Exact receipts precede fallible callbacks, re-entrant commits drain synchronously in sequence, and stable reset/disposal must converge the complete ledger to zero (`f46ddb5c`, `420e5eea`, `e937cc36`, `5ef8b537`, `ce3ac310`, `119b7c11`). |
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |

File diff suppressed because one or more lines are too long

View file

@ -178,20 +178,6 @@ package schema, bake, DAT reader, collision formula, or render portal graph
changed. Evidence:
`docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
**Cell availability semantics (2026-07-31, corrected after full-catalog
audit).** Raw and prepared CellStruct publication retains a `CellPhysics`
record when the physics root is empty but requires a valid containment root.
The installed 729,888-record raw and prepared catalogs contain zero rootless
containment payloads. A malformed null/-1 root is quarantined atomically; the
recursive inside base case applies only to a missing positive child below a
valid root. Registration-side outdoor floods still add outside cells but skip
transit when the active CLandCell is unavailable, and every later outdoor
candidate independently requires its own visible landcell before building
transit. The existing reflood retries after terrain/cell hydration. Both raw
and prepared point-in-cell paths preserve retail's zero-portals guard. No
package schema or DAT reader changed.
Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in

View file

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

View file

@ -1,381 +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
four 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). That
four-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"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
```
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) |
| `ACDREAM_AC_DIR` | `=<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 when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir``InteractionRetainedUiComposition.cs:610` |
| `ACDREAM_ANISOTROPIC` | `=<int>` (`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_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` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi``LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` |
| `ACDREAM_TEST_HOST` | `=<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 wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds``SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) |
| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=<int>` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) |
| `ACDREAM_STREAM_WORK_CPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) |
| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=<float percent>`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) |
| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) |
| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=<int>` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) |
| `ACDREAM_STREAM_WORK_GPU_MIB` | `=<int MiB>` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) |
| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=<double ms>` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) |
| `ACDREAM_STREAM_WORK_MS` | `=<double ms>` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) |
| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering``GameWindow.cs:765``DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` |
| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` |
| `ACDREAM_WORLD_TIME` | `=<float>`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction``GameWindow.cs:720``WorldEnvironmentController``Runtime.WorldTime.PinnedDayFraction` |
## Automation
A scripted route run adds three things at once — a session config so the
client self-selects a character, a route script, and an artifact directory:
```powershell
$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt"
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts"
$env:ACDREAM_FRAME_PROF = "1"
$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
& $exe --session-config "$scratch\session.json"
```
**Two traps this recipe exists to document:**
1. **Without `--session-config`, the client stops at character select** and
the route never runs. The session JSON supplies the endpoint, account,
and a character `index` for auto-selection.
2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the
render-scene oracle, which fingerprints every resident entity every
frame. The allocation cost was fixed in
[#432](ISSUES.md), but the CPU walk remains — automation-run frame
rates are diagnostics-loaded and must only be compared against other
automation runs, never against a plain run. Some route verbs
(`wait world-*`) additionally do nothing unless this is set.
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=<path>` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353``AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory``FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` |
| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer``GameWindow.cs:852` (`CreateStartupWindowOptions`) |
| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` |
| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) |
| `ACDREAM_NET_DROP_PCT` | `=<int 0-100>` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` |
| `ACDREAM_NET_DROP_SEED` | `=<int>` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) |
| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster&lt;55-slot ghost gate, so this flag is now a bypass of that gate for automation/testing rather than the only way in. | `false` | `RuntimeOptions.OpenCharacterCreationOnStart``CharacterCreationUiController.cs:21,524-530` |
| `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump``LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` |
| `ACDREAM_UI_PROBE_SCRIPT` | `=<path>` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript``InteractionRetainedUiComposition.cs:1094` |
| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=<feature-name>` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature``VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` |
| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe``GameWindow.cs:828``VulkanBringUpHost` |
| `ACDREAM_VULKAN_PROBE_FRAMES` | `=<int>` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames``VulkanBringUpHost.cs:141-249` |
| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth``GameWindow.cs``MovementTruthDiagnosticController` |
## Permanent diagnostics
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=<path>` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) |
| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` |
| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class |
| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 |
| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` |
| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) |
| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) |
| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` |
| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` |
| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` |
| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` |
| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` |
| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` |
| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family |
| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` |
| `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 |
| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized) with elapsed ms, 1 Hz progress, and one `SUMMARY` line at viewport reveal | print-only; doc comment: "never constructed unless the probe env is set, changes no behavior, and costs one branch per `Evaluate` poll otherwise" (i.e. genuinely near-zero cost when off — confirmed, `RevealTimingProbe` object itself is null when disabled) | unset (off) | `StreamingDiagnostics.ProbeRevealTiming` (`StreamingDiagnostics.cs:65-66`), consumed by `LandblockPresentationPipeline.cs:69`, `PublicationTimingProbe.cs:34`, `RevealTimingProbe.cs` (construction gated) |
| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) |
| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` |
| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` |
| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) |
| `ACDREAM_DUMP_CELLS` | `=<comma list of hex cell ids>` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) |
| `ACDREAM_DUMP_CELLS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` |
| `ACDREAM_DUMP_GFXOBJS` | `=<comma list of hex GfxObj ids>` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage), pair of DUMP_CELLS. | file I/O once per matching id (no-op on repeat) | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) |
| `ACDREAM_DUMP_GFXOBJS_DIR` | `=<dir>` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` |
| `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions``_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) |
| `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` |
| `ACDREAM_HIDE_PART` | `=<int>` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex``LivePresentationComposition.cs:608``LiveEntityAnimationPresenter.cs:21,38,243` |
| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` |
## Temporary probes
Each row names the issue that owns it. **A temporary probe is deleted in
the same commit as its investigation's fix** — if you find one here whose
issue is closed, the strip was missed; delete both.
> **Probe debt, measured 2026-08-24:** 64 temporary probes existed, citing 21
> distinct issues with 14 already closed. [#435](ISSUES.md) part 1 stripped
> the 17 rows whose investigation had ended without the strip — see the
> Retired section below for their removal record — leaving 47. Part 2
> traced each of the (then-)14 unattributed rows to its introducing commit
> and stripped the 7 that belonged to closed investigations
> (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37,
> `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8,
> `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor
> campaign, `ACDREAM_DUMP_VITALS`/#5). An eighth,
> `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day:
> it turned out to be automation apparatus, not a probe — the canonical
> nine-stop soak hard-gates on its output (see its row under Automation;
> #437 is the record). The rest of the attributed rows were reclassified
> into Permanent diagnostics as standing tools rather than investigation
> probes, leaving **31 temporary probes, every one attributed to an owning
> issue or campaign**. Each still costs a branch on its hot path even when
> unset, and a handful re-read the environment per frame rather than
> caching (see their side-effects column).
| Flag | Owning investigation | Value | What it does | Side effects | Read by |
|---|---|---|---|---|---|
| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` |
| `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic |
| `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. |
| `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ``SessionPlayerComposition.cs:280``LandblockBuildFactory.cs:23,42,168,335` |
| `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` |
| `ACDREAM_LIGHT_DEBUG` | #176 | `=<int>` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` |
| `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` |
| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` |
| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` |
| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` |
| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` |
| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` |
| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B |
| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` |
| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` |
| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` |
| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` |
| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` |
| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` |
| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` |
| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` |
| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` |
| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` |
| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` |
| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` |
| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=<comma-separated hex GUID list>` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) |
| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` |
| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` |
| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` |
| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` |
| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` |
## Deprecated
| Flag | Value | What it does | Side effects | Default | Read by |
|---|---|---|---|---|---|
| `ACDREAM_DEVTOOLS` | `=1` | logs a one-time "ImGui dev UI removed" notice; the only remaining functional consumer is `VulkanGraphicsContext.cs:184` (`enableOptionalExtensions: _options.DevTools`, selects optional Vulkan validation/debug-utils extensions) | real effect: turns on Vulkan validation/debug-utils extensions (can change perf and can surface validation-layer errors that don't occur when off) — NOT measurement-neutral for a perf gate; `GameWindow.DevToolsEnabled` is a hardcoded `false` const (dead — no ImGui dev UI exists to gate); `DevToolsInputCaptureSource(bool enabled)` explicitly discards its `enabled` ctor arg (`_ = enabled;`) — dead parameter, always reports `WantCaptureKeyboard=false` | off | `RuntimeOptions.DevTools` (typed, `Program.cs`/`RuntimeOptions.Parse`) |
| `ACDREAM_STREAM_RADIUS` | `=<int>` (non-negative) | Legacy override for the streaming near/far radii, applied on top of the quality-preset's radii at session-start composition. | **CLAUDE.md explicitly documents this as "legacy" and warns against using it for measurement.** Confirmed in code (`SessionPlayerComposition.cs:256-259`): `nearRadius = legacyRadius; farRadius = Math.Max(legacyRadius, farRadius)` — it FORCES `NearRadius` and only ever RAISES (never lowers) `FarRadius`. It is set once at session-start composition and is **silently discarded** by any later Settings quality change: `RuntimeSettingsController.ApplyQuality``RuntimeSettingsTargets.ApplyQuality``StreamingController.ReconfigureRadii` recomputes radii straight from the quality preset with no knowledge of this override. A measurement/gate run taken with this set is measuring a different streaming window than production and than any run that later touches Settings. | `null` → quality-preset radii unmodified (production default: High preset, Near 4 / Far 12) | `RuntimeOptions.LegacyStreamRadius``SessionPlayerComposition.cs:254-268` |
---
<!-- retired -->
## Retired
Flags that no longer exist, kept only so a stale script or an old research
document does not send someone hunting. Rows below this marker are exempt
from the "must still exist" check.
| Flag | Retired | Replacement |
|---|---|---|
| `ACDREAM_RUN_SKILL` | Client-side run-skill override for local motion prediction. Skills now arrive from the server (`LiveMovementStatsApplier`); the hardcoded fallback is 200. | none — server-authoritative |
| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative |
| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none |
| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none |
| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` |
| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none |
| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_LIGHT` | #133 A7 dungeon-lighting `[light]`/`[light-detail]` trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_OUTSTAGE` | #131 outside-stage dynamics routing trace (also the `ACDREAM_DUMP_ENTITY` `[outstage-own]` watchlist consumer). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_PHANTOM` | #113 phantom-shell/phantom-objs draw-mechanism trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_REACH` | #334 broadphase candidate-disposition trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_REMOTE_LANDING` | Bug A / issue #32 remote ground-contact landing trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_SHELL` | #78 cell-shell opaque-pass render trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_STEP_HEIGHTS` | Issue #338 step-up/step-down height provenance trace (including its unconditional once-per-process `AnnounceStepHeightProbeOnce` self-report). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_STICKY` | R5-V3 issue #171 sticky-melee lifecycle/steer trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_SUPPORT` | Issue #337 `[support]`/`[geom]` collision-vs-visual classifier trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_TEXFLUSH` | #105 white-indoor-textures staged-upload trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_VIEWER` | #119-residual viewer/flood capture (tower-ascent replay). Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none |
| `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none |
| `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none |
| `ACDREAM_A8_DUMP_PV` | Phase A8.F portal-frame visual-gate triage dump (camera-cell portal census + EXIT-PROJ/EXIT-CLIP/EXIT trace in `PortalVisibilityBuilder.Build`). Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_CLOTHING` | Issue #37 humanoid-coat clothing/part-swap trace. #37 closed 2026-05-11; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_EDGE_SLIDE` | Issue #32 L.2c edge-slide/cliff-slide branch trace (five `edge-slide:` lines). #32 closed 2026-08-07; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_LIVE_SPAWNS` | Phase A8 indoor-visibility batch live-spawn/DROP trace. Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none |
| `ACDREAM_DUMP_STEPUP` | L.2.3d/e/f step-up `stepup: enter/SUCCESS/FAILED` trace. Investigation closed; stripped 2026-08-24 (#435 part 2) — its content is still covered by the separate `[transit-fail-stepup]` line under `ACDREAM_DUMP_TRANSIT_FAIL`. | `ACDREAM_DUMP_TRANSIT_FAIL` |
| `ACDREAM_DUMP_VENDOR` | `[vendor-diag]` trace (~25 call sites) for two vendor-approach/split-stack regressions. Vendor campaign closed 2026-08-08; stripped 2026-08-24 (#435 part 2) along with its owner class `VendorDiagnostics.cs`. | none |
| `ACDREAM_DUMP_VITALS` | Issue #5 `PrivateUpdateVital`/`PlayerDescription`/parse-failure trace across 4 sites. #5 closed 2026-04-25; stripped 2026-08-24 (#435 part 2). | none |

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-08-14. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Status:** Living document. Updated 2026-07-27. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
**Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical
@ -22,9 +22,8 @@ falsification ledger — is [`2026-07-27-vulkan-campaign.md`](2026-07-27-vulkan-
The user signed the V10 cutover; V11 deleted GL (27,670 lines) and all
deferred reruns pass on the GL-free tree.
**Campaign N — retail reliable network transport (CLOSED 2026-07-29,
user-accepted; #260 closed — a real wire loss recovered live during the
acceptance session):** the #260 live-server wedge root-caused to missing packet-loss
**Campaign N — retail reliable network transport (ACTIVE, started
2026-07-29):** the #260 live-server wedge root-caused to missing packet-loss
recovery in both directions (no outbound retransmission; inbound ISAAC burned
in arrival order) — acdream could not survive a single lost UDP packet, and
loopback gates were structurally blind to it. The campaign ports retail's
@ -35,138 +34,9 @@ N0N6 with a permanent loss-injection gate at N5 and a user Coldeve
endurance session as final acceptance. The plan is
[`2026-07-29-network-transport-campaign.md`](2026-07-29-network-transport-campaign.md).
**Campaign P — physics retail-feel parity (CLOSED 2026-07-31):**
user-directed pre-vendor detour closing every physics-scope gap the
2026-07-29 audit found: stat-coupled movement (burden/stamina/vitae →
run/jump), the collision response-layer edge family (friction gate,
PrecipiceSlide, sled, steep-poly chain, #116), remote-object residuals
(Setup sphere lists, PK bits, #165), world specials (entry restrictions,
water sink-in), deferred fidelity (#167, #153), the #262 login defect, and
a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP
rows, one final batched connected visual matrix. Sonnet implements, Opus
reviews. The plan is
[`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md).
The 2026-07-31 #268 stat-chain package is implemented and user-accepted:
panel and Runtime movement share retail's complete augmentation ordering,
the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus
TS-8 are retired by focused and end-to-end packet tests. #269's
capture-driven slope-slide residual is also closed and user-accepted:
`CTransition::validate_transition` now performs retail's non-OK-only
remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`.
The matrix then exposed #272: burden was invalidated by base Strength but not
by Strength enchantment add/purge. Runtime movement plus both retained burden
surfaces now use effective Strength and the canonical enchantment-change edge;
automated gates pass and the connected buff/death gate was user-accepted on
2026-07-31. The final session also accepted burden/exhaustion, wall/corner,
crowd, two-client remote/door/portal, and shallow-water behavior. The user
waived the general sweep and explicitly deferred the barred-house gate as
#274. The later exact-location #273 tight-gap gate is now fixed and accepted.
**Campaign A — audio retail parity (CODE-COMPLETE 2026-08-08):** ported
retail's CPU-side 2-D pan+gain audio model (animation-hook sounds, the
`0xF750` server sound channel, PhysicsScripts, the interface sound bank,
and the region ambient system); found and corrected three false claims in
the old E.2 row (no 3-D pool, gain-based eviction, weighted variant
picking) and deleted the music API (retail has none). Slices A1A6 landed;
open tail is #358 (Ctrl+M mute chord never fires) and the formal
plan-status flip. Plan:
[`2026-08-08-audio-parity-campaign.md`](2026-08-08-audio-parity-campaign.md).
**Campaign CH — chat & interface-text retail parity (CODE-COMPLETE
2026-08-09, pending the connected user gate):** four implementation
slices closing the chat surface's biggest retail-parity gaps ahead of the
friend-alpha: CH1 the complete 34-value `LogTextType` color table, CH2
the on-screen SpewBox interface text (jump-refusal class, WeenieError
routing table), CH3 side-channel membership/wire/echo parity (Turbine
rooms, the legacy family, self-echo), and CH4 command registry completion
(138 of 152 retail verbs now execute locally). Every slice landed a
dual-Opus review (retail faithfulness + architecture) before its gate;
full Release suite 12,221 passed / 4 skipped / 0 failed. Plan and ledger:
[`2026-08-09-chat-parity-campaign.md`](2026-08-09-chat-parity-campaign.md);
in-client acceptance script:
[`2026-08-09-campaign-ch-test-script.md`](../research/2026-08-09-campaign-ch-test-script.md).
**Campaign LA — launcher/installer/updater + retail character-select
(ACTIVE 2026-08-14):** the alpha-program launcher: an Avalonia app
(Windows + Linux) doing triple duty — install (DAT locate → `acdream-bake`
with progress → SHA record), update (GitHub Releases manifest, verified
download, atomic version swap, launcher self-update), and launch
(ThwargLauncher-model server × account × character profiles with full
in-UI CRUD; plaintext credential file by explicit user decision).
File-contract orchestration of both hosts: session config in (K1 shape +
plugins + login commands), password via child stdin, versioned JSONL
status events out. Adds the headless character-list probe, plugin hosting
+ login commands on both hosts, and the retail character-management
screen (recon-corrected: `gmCharacterManagementUI` is a flat listbox with
Enter/Delete/Restore — NO 3D preview on retail's select screen; Create is
a future campaign). Spec:
[`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md);
plan + ledger:
[`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md).
LA0 through LA11's automated scope are review-closed: the portable path boundary,
failure-isolated launch/status contract, BCL-only launcher core, shared
composer-to-both-host-loader anti-drift gate, and character wire messages are
landed. The self-contained Avalonia launcher, transactional two-host plugin
lifetime, shared login-command route, Runtime-owned retail selection state,
authored DAT character screen, crash-safe verified installer, and atomic
cross-platform updater/self-updater are integrated. Windows group-isolated
Headless stop, isolated A/B update fixtures, strict status/redaction evidence,
and one exact Windows/Ubuntu operator script are also landed. The integrated
clean preflight passes 32/32 commands and 14,012 tests / 5 skips. Campaign code
is complete but not shipped: the connected/visual/real-DAT user gate is the
only remaining boundary.
**Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then
authorized retirement of the remaining proven collision/placement gaps before
vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell
availability, atomic collision generations, canonical Core SetPosition,
Runtime lost-cell residence, authored mover/body preparation, placement
receipts and observers, collision-prefix replacement, authoritative route
classification, and initial Create residence are landed as bisectable
checkpoints. Commit `38fd4b8d` retains the accepted Create placement plus
fresher Position FIFO until exact placement and ordered adoption complete.
Commit `30012361` completes the bounded inbound-admission checkpoint: all
accepted mixed updates remain deep-frozen in exact arrival order while the
initial placement waits, with no early canonical/public snapshot, event, or
presentation mutation. Missing-parent raw Create, delete, reconnect/reset,
GUID reuse, malformed projections, saturation, and reentrant teardown are
covered. Commit `5db3de3c` (2026-08-02) completes the continuation executor:
retry-idempotent single adoption of the acknowledged initial placement,
retail's exact Create tail, GUID-keyed deferred-child and parent-relation
replay with cancellation-aware windows, strict-sequence mixed-FIFO drain with
execution-time retail Position routing, shared apply bodies keeping one
snapshot store in lockstep, and converged ownership ledgers on every
abandonment path — dual independent reviews PASS; register rows
AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 filed in the same commit.
Production initial Create registration is now cut over by C3c (`529e0e9d`).
The O(changed) collision-publication checkpoint and five stabilization fixes
through `175ad6b0` restore recenter convergence, remote world-frame placement
and targeting, distant Use, one-shot spell/projectile/static effects, and
login materialization; the corresponding connected user gates passed. The
fixture reconciliation closed 2026-08-03 as #281 (the recorded "six" measured
as 43). **C4 is implementation-complete 2026-08-05**: routes 2 (`9966b531`,
user-accepted), 4a (`44830a0e`), 4b-1/4b-2/4b-3
(`2e8e09ac`/`7f1c1f5a`/`6dc7ba51` — 4b-2's far-snap and 4b-3's teleport-ts
connected gates user-passed), 5 (`36255af0`, test-gated by design — ACE never
sends a missile UpdatePosition), 6 (`1b484937`, a zero-production-line
closure whose coverage tests found and fixed #314), 7 (`cd3129e9`, child-cell
propagation moved from a render tick into Runtime), and 3 (`e0f96a55`, the
canonical portal placement authority) all place through the canonical Runtime
owner; the complete Release suite measures 11,090 passed / 4 skipped /
0 failed. The campaign remains open for C4's four owed connected gates
(route 6 drops; route 7 equip/carry counted only with `cause=propagate`
probe lines; route 3 portal/recall counted only with `[local-tp]` probe
lines and not scored against #318; 4b-3's `cause=cellless` case, whose
recorded trigger route 7 invalidated), portal destination prefetch #280, the
final-binary C5 legacy-deletion/suite/soak/visual matrix, AP-22 authored
object shapes, and AD-10 remote contact-plane projection.
Current plan and successor handoff:
[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md) and
[`2026-08-05-c4-closeout-handoff.md`](../research/2026-08-05-c4-closeout-handoff.md).
---
## Paused program: world interaction completion (M4 prelude)
## Current program: world interaction completion (M4 prelude)
The active work order is
[`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md).
@ -463,7 +333,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ |
| D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ |
| E.1 | Motion-hook expansion — AnimationSequencer fires all 27 hook types per crossed frame; PosFrames root motion + vel/omega exposure; IAnimationHookSink + AnimationHookRouter fan-out | Tests ✓ |
| E.2 | Audio engine — OpenAL voice bank, SoundTable/Wave decoding, AudioHookSink wiring. **Superseded 2026-08-08 by Campaign A** (`docs/plans/2026-08-08-audio-parity-campaign.md`), which found three of this row's claims false: the pool was not 3-D in retail (every gameplay buffer is 2-D and spatialization is CPU-side), eviction compared gain rather than retail's DAT priority, and the "probability-weighted variant picking" was the defect itself — probability is a Bernoulli silence gate, not a weight. Campaign A also added the 0xF750 server sound channel, the interface sound bank, and the region ambient system, and deleted the music API (retail has none). | Tests ✓ / listening gate owed |
| E.2 | Audio engine — OpenAL 16-voice 3D pool with retail-faithful quieter-slot eviction, SoundTable cookbook (probability-weighted variant picking), Wave PCM decoder, AudioHookSink wiring | Tests ✓ |
| E.3 | Particle system (data layer) — ParticleSystem with 13 motion integrators, EmitterDescRegistry, ParticleHookSink wiring all CreateParticle / DestroyParticle / StopParticle hooks | Tests ✓ |
| E.4 | Combat notifications + outbound — AttackTargetRequest (0x0008), 7 combat notification parsers (Victim/Defender/Attacker/Evasion/AttackDone/UpdateHealth), CombatState per-entity health tracker | Tests ✓ |
| E.5 | Spell cast wire — CastSpellRequest targeted (0x004A) + untargeted (0x0048), Spellbook (learned spells + active-enchantment layers), 5 enchantment GameEvent parsers | Tests ✓ |
@ -480,7 +350,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| I.3 | `LiveCommandBus` + `WorldSession.SendTalk` / `SendTell` / `SendChannel` — replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChannelResolver` legacy-id mapping (per holtburger). 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. | Tests ✓ |
| I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ |
| I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ |
| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ |
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
@ -979,7 +849,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.
@ -999,7 +869,7 @@ the way retail + holtburger expect.
- **✓ SHIPPED — I.3 — `LiveCommandBus` + `WorldSession.Send{Talk,Tell,Channel}`.** Replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChatChannelKind` enum + `ChannelResolver` legacy-id mapping (per holtburger). `WorldSession.SendTalk` / `SendTell` / `SendChannel` are 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. Commit `8e6e5a0`.
- **✓ SHIPPED — I.4 — `ChatPanel` input field + slash commands.** Enter-to-submit input field on `ChatPanel`; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on input focus. Commit `f14296c`.
- **✓ SHIPPED — I.5 — Holtburger inbound chat parity + Windows-1252.** `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. Commit `ff5ed9e`.
- **✓ SHIPPED — I.6 — TurbineChat codec + `ChatChannelInfo`.** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** Commit `ca968fc`.
- **✓ SHIPPED — I.6 — TurbineChat codec + `ChatChannelInfo`.** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** Commit `ca968fc`.
- **✓ SHIPPED — I.7 — `CombatChatTranslator`.** Retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to visible damage/evasion/miss/kill events; `AttackDone` was removed from chat after named retail + ACE proved its nonzero final status is control-only. Commit `3d26c8e`, corrected 2026-07-11.
- **✓ SHIPPED — I.8 — Docs alignment.** Roadmap (this file) + `docs/ISSUES.md` issues #14-#20 closed + `memory/project_chat_pipeline.md` crib + `MEMORY.md` index entry + `CLAUDE.md` UI strategy paragraph all updated to reflect Phase I shipped state. Commit `(this commit)`.
@ -2070,10 +1940,6 @@ Native macOS graphical support is not committed by this track. The current
mandatory renderer requires modern OpenGL capabilities beyond Apple's native
OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. The [Stage 2 connected and closeout gate](../research/2026-08-22-atmospheric-stage2-connected-gate.md) now closes every available machine-local ACE, dense A/B, long-lifetime, graphical-package, shader, Release, and complete-test row. The implemented two-client gate proved that ACE rejects two concurrent characters on the one available account and is ready for separate observer credentials without recording secrets. A distinct-account remote-player row, unavailable physical GPU classes, and final project-owner pack-off/pack-on acceptance remain external gates; Campaign AR is not yet declared shipped.
**SHIPPED 2026-08-23 — Campaign VM (VisualMaster):** the [VisualMaster campaign](2026-08-22-visualmaster-campaign.md) closed the [Campaign AR review](../research/2026-08-22-campaign-ar-review.md) findings with evidence: VM0 proved the pack-off path is the pre-campaign renderer (pixel identity on static content, CPU 13 %, allocation 25×); VM2 cdb-read retail's live detail path (single-pass texture stages, the z-fade dead for built meshes) and VM1 re-ported #226 to it; VM4 made the documents truthful (real hardware darkens, the fallback brightened); VM5 de-banded the volumetrics (#421 filed); VM3 put the post stack in linear light with a numerically neutral preset; VM6 added weather-driven foliage wind behind the render pack (scenery-only, cutout/trunk motions, casters follow, independent of the shadow gate) through five Opus review rounds and a repeat-floor pixel apparatus ([note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md)). VM7's automated gates ran 2026-08-23 on `99d5b2a6`: release gate 15,283/0/0, connected lifecycle/reconnect route PASS pack-off and pack-on, final pack-off vs `base+normals` robust diff 510 px, #422 not reproducible in 40 instrumented runs. The owner's VM3 (linear light) and VM6 (wind) live gates passed 2026-08-23 at 2560×1440 with the High pack; two defects found during those gates were fixed the same session (#424 alt-tab zero-area frame crash, #425 resolution-blind resident budget + activation lockout). #422 (rare heap-corruption exit at process close, pack-independent) is carried open by the owner's decision. Merged to main as a fast-forward of 54 commits.
---
## Cross-cutting work tracked in parallel
@ -2119,7 +1985,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** ✓ |
@ -2135,10 +2001,9 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
| Can't fight monsters | **M2 LANDED 2026-07-15** ✓ — melee/missile, death, loot, inventory loop user-gated |
| Can't cast spells | **M3 connected single-client casting/effects gate passed** ✓; final two-client portal observer gate remains |
| No inventory panel | **D.5 / M2 SHIPPED + user-gated** ✓ — bags, stacks, paperdoll, equipment, drag/drop, loot |
| No player-to-player trading | **Secure trade SHIPPED + two-client user gate PASSED 2026-08-14** ✓ — gmSecureTradeUI window (LayoutDesc 0x2100000D), full 0x1F60x208 wire, both retail open paths (Use-on-player, drag-item-onto-player option), staged-item trading marker, cancel text; research `docs/research/2026-08-14-trade-lane{A,B,C}-*.md`, memory `project_secure_trade.md` |
| No character creation — must use ACE admin | **Phase H.4** |
| Sky is a flat color | **Phase G.1** (shipped; F7 cycles time, F10 cycles weather) |
| Can't join allegiance | **Phase H.2** |
| ~~No quest tracker~~ | **SHIPPED 2026-08-21** — the Journal panel's Contracts tab (Campaign QT) |
| No quest tracker | **Phase H.3** |
If you see something not on this list, add it here and assign a phase.

View file

@ -87,40 +87,8 @@ program: spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. This is deliberately using the extracted interaction owners and
canonical shared main-panel host before quest/emote/character-creation bodies
broaden the feature surface. Slices 14 are user-accepted. Campaign P's
connected feel matrix closed on 2026-07-31 with tight-gap collision clearance
(#273) and the deferred restricted-house gate (#274) explicitly carried. The
user subsequently authorized the remaining physics-divergence closeout before
vendor work. Placement Slice 4B2 is now complete through dormant SetPosition
activation, graphical/no-window placement receipts, collision-prefix
replacement, authoritative route classification, pre-placement App staging,
and Runtime's initial Create residence/FIFO transaction at `38fd4b8d`.
The bounded admission checkpoint is complete at `30012361`: every accepted
same-incarnation Create, ObjDesc, Parent, Pickup, Position, Movement, State,
and Vector update is retained as a deep-frozen, arrival-ordered Runtime action
without changing the canonical/public snapshot or presentation while initial
placement waits. The continuation executor is complete at `5db3de3c`
(2026-08-02): one retry-idempotent Runtime `Execute` transaction adopts the
acknowledged initial placement exactly once, applies retail's Create tail,
replays deferred missing-parent raw Creates and queued parent relations by
parent GUID with cancellation-aware detach/restore windows, and drains the
mixed FIFO strictly by sequence with execution-time retail Position routing
through the canonical SetPosition lifecycle. Independent retail-conformance
and architecture/adversarial reviews both PASS after five implementation
rounds; register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document
the slice's deviations; Runtime tests 903/903, complete Release solution
10,696/4 skips. Production initial Create registration is now cut over by C3c
(`529e0e9d`). The O(changed) collision-publication checkpoint and
stabilization fixes `01f4791e`, `670f307c`, `1fc529cd`, `f24532ad`, and
`175ad6b0` are connected user-accepted for recenter convergence, remote
monster/static placement and targeting, distant Use, spell/projectile/static
VFX, and login materialization. The remaining order is six selected-fixture
reconciliations, C4 routes 27, portal destination prefetch #280, C5's
final-binary complete suite/soak/two-client matrix, AP-22 shape fidelity,
AD-10 remote contact-plane projection, and final ledger closeout. Resume Slice
5 vendor browsing only after that closeout or a new explicit user direction.
Canonical checkpoint:
[`2026-08-02-placement-cutover.md`](2026-08-02-placement-cutover.md).
broaden the feature surface. Slices 13 are user-accepted; resume at Slice 4
equipped-child picking.
The separately authorized modern-runtime performance program has completed
Slices AD: corrected measurement, prepared-package bake/dedup, package-only

View file

@ -104,7 +104,7 @@ inline when ported.
### BR-1 — The surface gate — ✅ RESOLVED AS ALREADY-EQUIVALENT (2026-06-11, execution day 1)
**Premise falsified before implementation (the BR-1 pre-check,
`Diagnostic_ReplicateProductionEmission_OnPortalFills`):** acdream **already suppresses
`ReplicateProductionEmission_OnPortalFills`):** acdream **already suppresses
every portal fill** — all four extraction paths skip `Stippling.NoPos`
positive sides (`ObjectMeshManager.PrepareGfxObjMeshData:1046`,
`PrepareCellStructMeshData:1394`, `CellMesh.Build:44`, `GfxObjMesh.Build:71`),

View file

@ -14,10 +14,8 @@ component-disabled ACE characters to the modern scarab/prismatic formula,
resolves formula icons by their DAT icon DIDs, installs those icons as each
authored template root's own foreground image, and migrates stale examination
dimensions once to the authored 310 x 400 extent. The final connected
assessment gate passed on 2026-07-24. Slice 4 equipped-child world picking
(with the Opus F1 wielded-pickup-legality correction) passed its connected
visual gate on Coldeve and was user-accepted 2026-07-29. Slices 14 are
complete; resume at Slice 5, vendor browsing.
assessment gate passed on 2026-07-24. Slices 13 are complete; resume at Slice
4, equipped-child world picking.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell.
@ -409,8 +407,7 @@ Named retail references and executable pseudocode are recorded in
## Slice 4 — equipped-child world picking
**Status:** USER-ACCEPTED 2026-07-29 — the two-client visual gate passed on
Coldeve ("child world picking works"). Owner
**Status:** implemented 2026-07-29, pending the two-client visual gate. Owner
shape per the program table held: pure world-query/picking policy plus
presentation anchor. No wire, physics, renderer, or
`EquippedChildRenderController` changes. `LiveEntityRuntime` gained scoped
@ -554,211 +551,3 @@ already published per frame to `EntityEffectPoseRegistry` (`PublishChildPose`,
- Existing architectural divergence, unchanged by this slice: retail re-arms
the pick every frame for hover/tooltips (`sr_MouseOver`); acdream picks on
demand per click against the last published frame, with an identity recheck.
## Slice 5 — vendor browse lifecycle (contract authored 2026-08-08)
**Research foundation:**
`docs/research/2026-08-08-slice5-vendor-browse-research.md` (all wire,
retail-symbol, and seam citations live there — this contract only records
DECISIONS and ordered work). Browse only; every buy/sell/accept concern is
Slice 6 (see the research doc's §D fence).
### Decisions on the research doc's eight open questions
1. `VendorState` lives in `AcDream.Core.Items`, a sibling of
`ExternalContainerState`.
2. The shared `PublicWeenieDesc`-body parser IS extracted from
`CreateObject.TryParse` FIRST, as its own behavior-preserving commit
(5.0). Existing CreateObject wire tests must pass unchanged; the
extraction adds no parsing behavior.
3. `ShopSystem::BuyPrice`/`SellPrice` (0x006B6120/0x006B6180,
byte-identical to ACE's `GetBuyCost`/`GetSellCost`) are ported NOW as
pure Core functions with golden-value conformance tests — the browse
list shows retail-correct prices from day one.
4. No request-correlation token in Slice 5: the panel always opens on the
browse/Buy tab. Slice 6 adds the sell-initiated correlation.
5. `VendorProfile::InqAcceptability` (which player items the vendor would
accept) is deferred to Slice 6 with the sell UI it gates.
6. Category/type filter tabs are IN SCOPE for retail parity. The
implementer's D0 reads `VendorItemsUI::AddTypeFilter` /
`ListContainsType` (around 0x004C05C0/0x004C0D90) into a pseudocode
note before any UI work; if that read reveals a mechanism too large
for this slice, STOP and report (fallback — flat list + register row —
requires explicit approval, not implementer discretion).
7. The vendor panel's top-level LayoutDesc id is NOT yet known: the UI
piece budgets a LayoutImporter discovery pass (the exact process that
found the examination window's 0x2100006B), cross-checked by the two
known tab-control ids (0x100000B9 Buy / 0x100000BB Sell) resolving
under the candidate root.
8. AP-110 is narrowed in the SAME COMMIT that lands the panel: "vendor"
leaves the absent-panels list; whatever sub-scope remains absent after
this slice gets its own precise row.
### Ordered work (each lands separately, bisectable)
- **5.0** — extract the shared `PublicWeenieDesc`-body parser
(behavior-preserving; wire tests unchanged; no vendor code).
- **5.1**`ApproachVendor` (GameEvent 0x0062) inbound parser:
`VendorProfile` + the full-desc item list, against the research doc's
byte-verified field table; Core.Net tests with golden byte fixtures.
- **5.2**`VendorState` in Core.Items + the BuyPrice/SellPrice pure
port + conformance tests.
- **5.3** — Runtime ownership: `RuntimeInventoryState` owns the vendor
session per the J4.2 pattern (generation-gated, torn down on
reset/portal/logout); the 0x0062 route opens it; close is CLIENT-LOCAL
(nothing sent on the wire) via the retail distance-watcher semantics;
`ItemInteractionController._activeVendorId` /
`ItemInteractionPolicy.ActiveVendorId` finally receive the real id.
- **5.4** — the authored vendor panel: layout-id discovery, LayoutDesc
import via the Slice-3 examination-window pattern (foreground stacking,
authored extent), browse list reusing Slice-1's retained list/scrollbar
+ DAT icon resolution, category tabs per the D0 read, prices via 5.2.
- **5.5** — register narrowing (decision 8) rides the 5.4 landing commit.
### Trap list (binding)
Do not touch: the J5.2 strict use gate's semantics (the vendor open rides
the EXISTING use transaction — no second gate, per the J4.5 invariant);
`CreateObject.TryParse` behavior (5.0 is extraction only); anything in the
Slice 6 fence (no buy/sell wire, no currency mutation, no
InqAcceptability). New event handling follows the newest existing
GameEvent handler's registration pattern, not a bespoke route.
### Gates
Per landing: build + full suite green (clean-room before each landing
commit). Slice gate (user, connected, ~3 min): approach a Holtburg
vendor, use them, the authored panel opens on the browse tab with
retail-correct items/icons/prices; category tabs filter; walking out of
range closes the panel by itself; nothing is purchasable anywhere.
## Slice 6 — vendor transactions, buy arc (contract authored 2026-08-08; user-pulled forward)
**Research:** `docs/research/2026-08-08-slice6-vendor-transactions-research.md`.
User direction: "I cant buy anything... Fix that first." Root cause of all
four reported symptoms: `VendorUiController` never touches the shared
`SelectionState`/`StackSplitQuantityState` owners every other panel uses.
### Decisions
1. **Shop items materialize into `ClientObjectTable`** while the session is
open (retail creates real CWeenieObjects from the vendor list — Slice 5
research §A.2) and are REMOVED on session close/replace/reset. The
implementer verifies retail's removal site (gmVendorUI::CloseVendor
family) and mirrors its lifecycle. This dissolves F7c's blocker:
`ExamineItemRequested` gets wired in this slice.
2. **Vendor selection is the GLOBAL selection**: a new vendor change source
on the canonical `SelectionState`; row-click selects through it; the
status bar and the existing byte-faithful `StackSplitQuantityState`
slider follow automatically (the split-size mask helper from 5.4's F2
feeds the vendor-owned seeding exactly as gmToolbarUI does at
pc:198635-198790).
3. **Buy = retail's Buy button**: immediate single-item purchase
(gmVendorUI::BuySingleItem, pc:201661) of the selected item with the
slider-chosen quantity for stacks. Outbound `0x005F`: vendorGuid,
count, per-item (i32 amount, u32 guid), TRAILING u32
alternateCurrencyId — the real client sends it (CM_Vendor::Event_Buy,
pc:689288) even though ACE's reader ignores it; we port the real
client. The request rides the EXISTING J5.2 one-request-at-a-time
reservation and completes on `UseDone` (0x01C7) — already the wired
completion signal; no second gate.
4. **Reconciliation is the existing inbound machinery**: money property
updates, inventory CreateObject, and the ApproachVendor refresh
(VendorState.Refreshed) all flow through landed handlers — the slice
VERIFIES the loop end-to-end rather than adding an owner.
5. **No double-click-to-buy**: retail has no such mechanism (confirmed
against the full named table). We match retail. If the user wants it
as a deliberate modernization it needs their explicit call + an AP row.
6. **Deferred, still AP-161**: the Add button / Buying-tab staging list
and everything Sell (0x0060 — researched, next arc).
### Ordered work (one implementer, bisectable commits)
- **6.1** shop-item materialization + removal lifecycle + examine wiring.
- **6.2** the selection coupling (source, row-click, split seeding) —
status bar + slider light up.
- **6.3** the 0x005F builder (golden-byte tests incl. the trailing dword),
Buy-button wiring, gate/UseDone completion, and the verified
reconciliation round-trip. Register: AP-161 narrowed in the landing.
### Gate (user, connected)
Select a stacked item → it shows in the status bar with the slider; pick
a quantity; Buy → coins drop by the displayed price, the stack lands in
the pack, the shop refreshes; a single-item buy works; insufficient funds
fails cleanly; the session still closes on walk-away/portal with the
materialized items removed.
## Slice 6b/6c — vendor completion (contract authored 2026-08-08)
**Research:** `docs/research/2026-08-08-slice6b-vendor-completion-research.md`.
Closes the user's seven-finding gate batch (dropdown polish landed at
`33b45ee5`). Ordered chunks, one implementer:
1. **Move-to-use (Q2):** wire the existing client-predicted approach
primitive (`PlayerInteractionMovementSink.BeginApproach`,
`MovementType.MoveToObject` — today Pickup-only) onto `RequestUse`, so
using a vendor (or anything) beyond range walks the player in first,
retail's shape. No new movement machinery.
2. **Buy staging (Q3):** `AddToBuyList` semantics per the research
trace — Add stages the selection + slider quantity into the Buying
tab's list (rendered count/price), Buy on that tab sends ONE batched
0x005F with every staged entry, the two removal shapes + Clear, and
retail's X-close confirm dialog when staging is non-empty (the current
unconditional hide stays correct only for empty staging).
3. **Selling (Q4):** the Selling tab's list is THE drop target — accept
pack-item drops via the `ExternalContainerController` drag-handler
pattern, filter through `InqAcceptability` (all four rejection reasons
with retail's exact strings), staged sell list, batched 0x0060, and
the existing reconciliation machinery.
4. **Status bar (Q5) — user evidence is the axiom:** the code-reading
says our chain already matches retail, but the user's live session
says the stack count/value/split-bar presentation is absent for a
vendor selection. Reproduce in a UI-level test FIRST (drive the real
SelectedObjectController mount with a vendor selection); fix what the
reproduction reveals; if it genuinely cannot reproduce, STOP and
report with the test as evidence for a live-probe session.
5. **Pack order (Q1):** code-verified correct (wire placement position →
front insert). NO change; the gate re-checks it live and #352 gets
filed only if it reproduces.
Register: same-commit rows for any deviation; AP-161 narrows again as
staging/sell land (its remaining scope should shrink to nothing or to
precisely what stays absent).
**Gate (user):** click a vendor from afar → walk-in + open; stage two
different items with quantities → Buy All → one transaction, coins/items
correct; drag a sellable item onto the Selling tab → stages → Sell →
coins up, item gone; an InqAcceptability-rejected item shows retail's
refusal; X with a staged list → confirm dialog; stacked selection shows
count/value/split-bar in the toolbar; bought items land at the front of
the pack.
## PROGRAM CLOSEOUT — 2026-08-08: all six slices COMPLETE, user-accepted
Slices 5 and 6 closed together after the vendor arc's final gates. The
complete retail vendor experience is live and user-verified end to end:
walk-to-use from afar (the never-animated-target physics-host resolver +
the cylinder-gap range watcher), the authored panel with the scrollable
category dropdown (arrow-cap, downward, left-aligned), retail cost
sentences with live purse repaint on every money change, per-unit and
whole-stack pricing per the split-size mask, the MaxStackSize quantity
slider with the right-justified count entry and two-line name wrap in
the toolbar, staged buying (accumulate + 5000 cap + shop-row decrement +
the four pre-send guards + the batched 0x005F + the X-close confirm),
selling (Selling-tab drop target with drag-over auto-switch,
InqAcceptability with verbatim rejection strings, BF_RETAINED, batched
0x0060), double-click-to-buy (AP-171, user-approved modernization),
prepend pack ordering (the cross-queue placement replay), and
materialized shop objects with ownership-checked lifecycle + examine.
Four adversarial Opus reviews found 34 defects before the user saw them;
the user's connected gates found eleven more that only live sessions
expose; two latent client-wide crashers (#348 cursor-handle exhaustion,
#350 render-ledger overflow) were exposed, root-caused, and fixed along
the way. Landed across `e45c95b0..af1a1ef9`. Deferred with issues/rows:
#352 (range-watcher cylinder unit test), AP-166's pending-sell
highlight, AP-167 (SellSingleItem's non-empty-container branch), AP-168's
shop-stock half, Buying/Selling staging polish beyond the landed scope.
This closes the pre-M4 world-interaction completion program.

View file

@ -1,420 +0,0 @@
# Campaign P — Physics Retail-Feel Parity
**Status:** CLOSED 2026-07-31 — final user matrix accepted; tight-gap
clearance issue #273 and deferred restricted-house gate #274 are explicitly
carried follow-ups.
**Filed:** 2026-07-29. **Directed by the user** as a pre-vendor-management
detour after the same-day physics/collision retail-fidelity audit. The
world-interaction program (Slice 5, vendor browsing) resumes when this
campaign closes.
**Execution model:** Claude drives autonomously slice-to-slice. Sonnet
subagents implement bounded chunks against this plan's specs; an Opus
review subagent gates every slice boundary. The only user stops are
(a) the final batched connected visual matrix, (b) a DO-NOT-RETRY
conflict, (c) anything destructive. All the CLAUDE.md workflow rules
apply: grep-named-first → pseudocode → port → conformance test; register
moves in the same commit; no workarounds.
---
## The parity goal (the autonomy contract)
**"Retail Movement Parity v1"** — the campaign is DONE when all of the
following are auditable-true:
1. **Zero physics-scope temporary stopgaps.** TS-1, TS-4, TS-5, TS-23,
TS-46 retired by porting the retail mechanism. TS-24, TS-35, TS-40
either retired or re-classified (IA/AD) with a recorded justification.
2. **No feel-affecting approximations left unargued.** AP-7 resolved by
decoding retail's friction state gate; AP-10 restored to retail's
0.1 m water sink-in; AP-25 replaced by the retail effective-skill
chain (vitae/enchantment-aware); AP-71 (`check_entry_restrictions`)
ported.
3. **Issue ledger:** #262, #165, #166, #116, #167, #72, #153 closed;
the stale "pending visual gate" statuses on #172/#173/#174/#175/#41
reconciled (folded into the final matrix below). Explicitly excluded:
#235 (user-deferred 2026-07-27) and #256/#257 (lifecycle/memory, not
physics feel — separate track).
4. **Verification:** each port carries decomp citations + conformance
tests; `dotnet build` + full Release suite green at every slice
commit; one batched connected visual matrix (below) passes at the
end, run by the user.
Anything not in this list is out of scope for the campaign — file it,
don't chase it.
---
## Slices
### P1 — Stat-coupled movement (burden / stamina / vitae) — retires TS-5, AP-25, the burden gap
**Status (2026-07-30): COMPLETE.** Landed at `9355ddce`; Opus review
APPROVE at `001e466d` (which also retired UN-8 — the CanJump polarity is
byte-PROVEN `load < 2.0` from the PDB-paired binary — and recorded the
PK-timer jump-cost decode for P3). Full Release suite 9,880/0/5 at the
slice gate. Retail's vitae/enchant chain reuses the M3 bucket-4
representation; `JumpStaminaCost` never refuses (weak-jump only) — the
plan's formula shorthand had the `+0.5` operand wrong and the
implementation follows the decomp's `(load+0.5)*power*8+2`. AP-127 was
filed for the then-bounded bonus properties and retired by #268 on
2026-07-31.
Today `PlayerWeenie.SetBurden` has zero callers, `CanJump` is always
true, `JumpStaminaCost` is 0, and pushed run/jump skill is
attributeBonus + init + ranks only. Retail modulates movement by
character state continuously.
**Retail anchors (named decomp, verified 2026-07-29):**
- `ACCWeenieObject::{CanJump 0x0058c400, JumpStaminaCost 0x0058c440, InqJumpVelocity 0x0058c520, InqRunRate 0x0058c560, InqMaxRunRate 0x0058c5a0}` — thin delegations to the qualities DB (pseudo-C ~406512).
- `CACQualities::{InqMaxRunRate 0x00591b20, CanJump 0x00591b50, JumpStaminaCost 0x00591b90, InqRunRate 0x00592800, InqJumpVelocity 0x00592980, InqLoad 0x0058f130}` (pseudo-C ~412901413975, ~409756) — the real load/skill/vitae composition. `InqJumpVelocity` ends in `sqrt(GetJumpHeight(...) * 19.6)` (pc 413975).
- `MovementSystem::{GetRunRate 0x006b0950, GetJumpHeight 0x006b09b0, JumpStaminaCost 0x006b0a40}` (pseudo-C ~695958) — GetJumpHeight readable: `LoadMod(load) * (skill/(skill+1300) * 22.2 + 0.05) * power / scaling`, floor 0.35; JumpStaminaCost readable: `ceil(((power + 0.5) * load) * 8 + 2)` on the arg3==0 branch.
- `EncumbranceSystem::{EncumbranceCapacity 0x004fcc00, Load 0x004fcc40, LoadMod 0x004fcc70}` (pseudo-C ~256393).
- Cross-refs: ACE `MovementSystem`/`EncumbranceSystem` C# ports; holtburger if it models load.
**Work:**
1. Port the full chain into Core (`EncumbranceSystem` + `MovementSystem`
statics; `PlayerWeenie` becomes the CACQualities-shaped composition).
Where BN x87 mush blocks a branch (GetRunRate body), use Ghidra MCP
or the ACE port as the tiebreaker and cite which.
2. Determine, from `CACQualities::InqRunRate`'s own body, exactly which
skill level retail feeds (base vs enchantment/vitae-adjusted) and
port THAT — scoped to the run/jump query path only, reading vitae +
relevant skill enchantments from the M3 active-effect state. Do not
build a general effective-skill engine.
3. Plumb the inputs from Runtime: burden (EncumbranceVal/capacity from
PlayerDescription + property updates), current stamina (vitals),
vitae. Extend `RuntimeMovementSkillState` (J4.4 seam) so updates flow
mid-session, same as run/jump skill today.
4. Wire the existing `jump_is_allowed` stamina-refusal branch and the
`ReportExhaustion` dual-dispatch gate (R3-W4 seam) to a real
consumer, matching retail's refusal/weak-jump behavior.
5. Conformance tests: formula tables (golden values incl. 800-skill cap,
load-mod knees at 100%/200%, stamina cost ceil), gating tests
(no-stamina jump refusal), plumbing tests (burden/stamina/vitae
changes move the produced rate). Register: delete TS-5 + AP-25 rows,
note the retirement in the same commit.
### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116
**Status (2026-07-31, FINAL):** TS-1 and AP-7 retired as originally
recorded. TS-4's first 2026-07-30 removal was accepted by an incomplete
resolver-only fixture, failed the live matrix with a roof wedge/uphill-bounce
regression, and was reverted. The 2026-07-31 closure began from a fresh
`BSPTREE::find_collisions` read and ports the exact asymmetric Path-6 split:
primary/foot hits use `SetCollide` + `LandingZ` + `Adjusted`, while
secondary/head hits use `CollisionNormal` + `Collided`; neither writes a
sliding normal. Both graph and prepared-flat implementations match that
oracle.
The corrective acceptance no longer calls the old horizontal-input fixture
"production-shaped." `Ts4ProductionQuantumConformanceTests` executes the
already-airborne, zero-root-motion 30 Hz Core collision tail — acceleration,
body integration, transition resolve, exact body/cell commit, then
`handle_all_collisions` — and retains its behavior-bearing cell, contact,
sliding, stationary-fall, and velocity state for 90 ticks. Graph and flat
match by raw bits for vertical/inward/tangential/downhill cases and a genuine
positive-Z uphill jump; exact terminal state, non-penetration, no fixed point,
and no second launch are pinned. No further product-code correction was
needed after that test became faithful, and there is no active AD-56 row. The
older resolver-only wedge test remains only as a historical three-second
signature control. #116 shape-2 remains closed; shape-1 remains narrowed as
recorded in its issue history. AD-55 remains retired by the raw-byte
`cos(10°)` proof.
The collision *response* layer (what happens after a hit): ground
friction, cliff edges, downhill landings, near-perpendicular wall
slides. One oracle-driven pass; the physics digest's DO-NOT-RETRY table
binds every subagent here.
**Work (research doc FIRST, then port):**
1. **AP-7:** decode the state gate on retail's friction block
(`calc_friction` region, pseudo-C ~276702-276705) that lets retail
use threshold 0.25 without hammering normal locomotion (the reverted
L.3c attempt). Ghidra MCP for the x87 branch if BN is garbled.
2. **TS-1:** port the `EdgeSlide → PrecipiceSlide / CliffSlide` chain
(precipice context, steep-plane bookkeeping) replacing our
stop-at-edge.
3. **#166:** port the landing "sled" (Sledding state set/clear sites;
the sled friction constants already sit in `calc_friction`).
4. **TS-4:** remove the Path-6 steep-poly shortcuts and port retail's exact
sphere split: primary/foot uses `SetCollide` + `LandingZ` + `Adjusted`;
secondary/head uses `CollisionNormal` + `Collided`. Remove every BSP-layer
`SetSlidingNormal` write (retail's only in-transition writer is
`validate_transition`).
5. **#116:** the near-perpendicular lateral-slide loss + first-airborne-
frame divergence, driven by the existing tick-22760 replay and D4
pins.
6. Apparatus: extend the trajectory-replay tests; capture fixtures
before changing behavior. Register: delete TS-1/TS-4/AP-7 rows.
### P3 — Remote-object residuals — retires TS-46, TS-23; closes #165; narrows/retires AD-25
1. **TS-46:** pass the Setup's verbatim sphere LIST into the transition
(`CPhysicsObj::transition 0x00512dc0 → init_sphere`) instead of the
two-scalar reconstruction, for local player and remotes; derive
remote step-up/step-down from the Setup instead of the pinned 0.4 m.
Captured-fixture replays must stay green or be re-baselined with
evidence.
2. **AD-25:** align the remote post-resolve with the ported
`handle_all_collisions` (grounded-bounce rule) as the player half
already did in #182.
3. **#165:** remotes visibly penetrate walls before stopping — diagnose
against the (now Setup-true) sweep; suspect list starts at the
catch-up step length vs sweep sub-steps.
4. **TS-23:** parse PlayerKillerStatus from PlayerDescription/property
updates and plumb PK/PKLite/Impenetrable onto local + remote player
movers (`OBJECTINFO::init 0x0050cf30` state bits). Non-PK ACE
behavior must be provably unchanged.
### P4 — World specials — retires AP-71, AP-10
**Status (2026-07-30): COMPLETE**, including a same-day Opus review
fix. AP-71 landed at `d6c3f865` (20 new conformance tests); AP-10 landed at
`cc8d57a2` (12 new conformance tests). Complete solution suite at that gate:
9,946 total across 9 test projects, 9,941 passed, 5 skipped, 0 failed on a
clean run. One run in the same session saw a single unrelated flake
(`AcDream.Content.Tests.Vfx.RetailDatLoaderTests
.AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel`, a
parallel-cache-coalescing timing test untouched by either commit) that
passed 3/3 in isolation and on the immediate re-run — full-suite parallel
contention, not a regression (independently fixed afterward at `dc0468cc`).
**P4 review verdict: FIX-FIRST (2026-07-30).** `RestrictionObjPrevalenceInspectionTests`
(`3b5e0992`) measured the installed cell DAT: 103,766 of 729,888 EnvCells
across 1,293 landblocks — the entire housing estate, `restrictionObj` GUIDs
`0x70xxxxxx` — carry a baked `RestrictionObj`. AP-71's fail-closed default
(landed with `CanMoveInto` deliberately unmodeled, per the original AP-129
row) would have locked every apartment/cottage/villa interior for every
player, including its own owner — a live regression, not the "inert in dev
content" the row assumed. Fixed at `7a0f836a`: `ACCWeenieObject::CanMoveInto`
(0x0058da40) and `RestrictionDB::IsAllowedIn` (0x005ae8f0) are now ported
verbatim, fed end-to-end from CreateObject's HouseOwner/HouseRestrictions/
Monarch PWD-tail fields (previously parsed-and-discarded) plus a new live
`House_UpdateRestrictions (0x0248)` parser, and resolved through a new
`PhysicsEngine.Objects` property wired to the canonical `ClientObjectTable`
in `RuntimeEntityObjectLifetime` (production fix, not just gate logic — an
unwired table still fails closed). AP-129 is narrowed (not retired) to the
genuine residual: no sequence-based staleness rejection for
`House_UpdateRestrictions` (low-probability, self-correcting), and the
outdoor `CLandCell` restriction path (a separate DAT structure) remains
unported, unaffected by this fix. Gate: `AcDream.Core.Tests` 4,049 passed / 2
skipped / 0 failed; `AcDream.Core.Net.Tests` 761 passed / 0 skipped / 0
failed; complete solution suite 9,961 total, 9,956 passed, 5 skipped, 0
failed.
1. **AP-71:** port the `CObjCell::check_entry_restrictions` gate at the
head of `find_env_collisions` (pc:309576) — barred house cells block
at the threshold client-side. Landed: the gate is wired at the top of
the indoor branch of `Transition.FindEnvCollisions`; `CellPhysics
.RestrictionObj` is fed from the DAT-baked `EnvCell.RestrictionObj`
field (§4.3's open question resolved via ACE's DatLoader + an
independent `Chorizite.DatReaderWriter` reflection probe — it's a
plain per-cell DAT field, not a live wire override) in both the dev
and production caching paths, at zero bake-format cost. The mover's
`CanBypassMoveRestrictions` (BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS)
is decoded via the TS-23 PWD-bitfield pipeline. The original landing
deliberately left `CanMoveInto` unmodeled (fail-closed default, filed
as AP-129) — the P4 review found this fails closed for the ENTIRE
housing estate and required the fix-first pass described above.
2. **AP-10:** restore retail's 0.1 m water sink-in; while there, verify
the water-contact step behavior (`WATER_CONTACT_TS` consumers)
against retail and file anything found. Landed: the dry-corner
constant is restored (full suite green — the sticky Contact/OnWalkable
bit argument held); `WaterContact` is now produced at every
`Contact`/`OnWalkable` commit site. No confirmed retail consumer of
`WATER_CONTACT_TS` was found this pass; filed as #264 along with two
other explicitly-unverified water items (the `ENTIRELY_WATER`
ethereal/swim terrain-collision exemption, and jump/swim
movement-effects) — none block this port.
**P4 review addendum (2026-07-30): APPROVED after one FIX-FIRST round.**
The initial AP-71 landing failed closed with `CanMoveInto` unmodeled; the
prevalence inspection (`3b5e0992`) proved that locks all 103,766 housing
EnvCells. `7a0f836a` ports `CanMoveInto`/`IsAllowedIn` verbatim
(owner/self/null-db admit; unresolved object blocks), captures the
previously-discarded HouseOwner/Monarch PWD fields, parses
`House_UpdateRestrictions 0x0248` live, and wires the canonical object
table into the gate. AP-129 narrowed to the sequence-byte and outdoor
RestrictionTables residuals. Suite 9,956/0/5.
### P5 — Deferred fidelity — closes #167, #153, #72
**Status (2026-07-30): item 1 (#167) COMPLETE.** Both blockers resolved
without Ghidra/cdb — the two x87-elided constants were byte-decoded
straight from the matching binary's raw machine code
(`docs/research/2026-07-30-constraint-leash-constants.md`). The leash is
now armed at every current acdream inbound-position acceptance seam
(`ConstraintDistance`, `LiveEntityNetworkUpdateController`,
`PlayerMovementController.SetPosition`/`BlipPosition`), the per-tick
`PhysicsBody.IsFullyConstrained` push replaces the always-false stub, and
register row TS-35 is deleted. Full Core/Runtime/App suites pass with new
conformance tests (leash-armed jump refusal, teleport-vs-blip
anchor/teardown, taper reduction, remote-tick push). Items 2 (#153) and 3
(#72) remain open.
1. **#167:** decode the two unknown x87 ConstraintManager constants
(Ghidra) and port leash arming.
2. **#153:** the far-teleport arrival onto an unstreamed landblock near
a 192 m edge — apparatus first (the issue's own trigger table), then
the streaming-gap hold shape ALREADY sketched there (freeze the
per-tick resolve until the landblock loads — the async equivalent of
retail's synchronous load; this is an AD row, not a workaround, and
gets filed as one).
3. **#72:** close on the R6 evidence (DAT-authored omega ±1.5 rad/s is
live; the cdb confirmation ask is obsolete).
### P6 — #262 login run-on-the-spot (live defect)
Probe-instrumented fresh-process login repros (`ACDREAM_PROBE_RESOLVE=1`
+ net probes) against local ACE; the issue's hypothesis list is the
script. Root cause, fix, regression test. No workarounds (no auto-recall,
no synthetic position kick). Runs serialized (owns the build tree +
client).
### P7 — Ledger + camera feel
1. Retire the stale TS-25 row (outbound stance ships via
`RawState.CurrentStyle` since #219) and refresh TS-24/TS-35/TS-40
classifications.
2. #115 camera-drag: investigation-only against `CameraManager`
constants (AD-37's vector-nlerp vs retail quaternion-slerp is the
prime suspect); fix if a concrete divergence falls out, otherwise
re-classify with evidence.
3. Reconcile #172/#173/#174/#175/#41 statuses via the final matrix.
---
## Implementation-phase closeout (2026-07-30) — awaiting the matrix
Every implementation slice is COMPLETE and Opus-reviewed; the campaign now
waits on the single user gate below.
**Register scorecard:** goal-enumerated physics stopgaps at ZERO — TS-1,
TS-4 (+ its FlatBspQuery twin), TS-5, TS-23, TS-35, TS-46 retired by
ports; TS-25 retired on #219 evidence; TS-24→AD-57, TS-40→AD-58
re-argued. AP-7, AP-10, AP-25, AP-71 retired; UN-8 and AD-55 retired by
raw-byte proof; AD-25 retired. AP-127 was subsequently retired by #268;
the remaining new argued rows are AP-128/129,
AD-53/54/55(retired)/56/57/58.
**Issues:** #72, #153, #167, #255 closed; #116 shape-2 closed /shape-1
narrowed to a probable harness artifact (response layer byte-verified);
#165 diagnosed to the render-lag candidate (matrix scenario 8a decides);
#166 all four composite deviations landed (scenario 5 decides); #262
apparatus permanently live + 3/3 clean probe logins (scenario 11
decides). Notable finds along the way: ACE's inverted leash-start
mapping, ACE's radians/degrees sled-constant bug (cos 10°), the
housing-lockout prevalence catch (103,766 restricted cells), and the
HouseOwner/Monarch PWD fields that were parsed-and-discarded.
**Verification:** every slice gated on the complete Release suite; final
state 9,977 passed / 0 failed / 4 skipped (the D4 un-skip retired one
permanent skip). Suite grew from 8,826 to 9,977 tests over the campaign
(+1,151, all conformance/golden/pin coverage).
## Final batched connected visual matrix (the ONE user gate)
1. Burden >100%: run slows, jump shrinks; ~200%: barely moves/jumps.
2. Repeated jumps drain stamina; low stamina → weak/refused jump;
exhaustion behavior matches retail.
3. Fresh vitae: movement penalty present.
4. Walk off a cliff/roof edge: slides over like retail, no dead stop.
5. Downhill jump landing: sled glide + bounce.
6. Shallow-angle wall graze: lateral slide preserved.
7. Packed crowd: spacing + shuffle-out unchanged (regression).
8. Two-client: remote stops at walls without visible penetration;
remote ceiling-jump bounces down immediately (#173); Holtburg portal
platform step-up (#172); door Use after jumping (#174); closed-door
collision matches the visual door (#175); observed-player blips
gone (#41).
9. Locked/barred house: blocked at the threshold.
10. Wading: slight retail sink-in.
11. ~20 fresh logins: no run-on-the-spot.
12. Regression sweep: walk/run/strafe/turn/jump/stairs/doors/water
edges feel unchanged from the accepted R6 baseline.
## Risk notes
- P2 and P3 touch the frozen-adjacent transition internals — every
subagent prompt must carry the digest's DO-NOT-RETRY table and the
no-workarounds rule; 3 failed attempts on any item = stop and build
apparatus, per [[feedback_apparatus_for_physics_bugs]].
- TS-46 changes the collision capsule of every mover; the captured
replay fixtures pin behavior — re-baseline only with a recorded
retail argument.
- P1's enchantment-aware skill read is the scope-creep risk; it is
bounded to the run/jump query path by this plan.
## Live-gate session 2 (2026-07-30) — speed + bounce family landed
The matrix's first live rows surfaced three defects; all three are
root-caused, retail-ported, and user-accepted in the same session:
- **#266 CLOSED** — run speed: retail `MovementSystem::GetRunRate`
(0x006b0950) treats 800 as an EXACT-EQUALITY sentinel; ACE's `>= 800`
reading is a misread of the same x87 mush that our P1 port inherited,
flat-lining every maxed character at 4.5 (retail-true ~3.70, +21%,
vitae-independent). Byte-decoded, fixed at `61e95916`; the [stat-chain]
live capture proved the vitae→skill chain correct end-to-end. Side-by-
side pace vs a retail client accepted by the user.
- **#265/#166 landing-momentum + bounce family** — two stacked fixes:
(1) `c60f6e5d` stopped hand-zeroing grounded residual velocity and
wired the never-written `GroundNormal` (roof slides restored);
(2) `2d611b2b` replaced the AD-25 landing adaptation with the retail
mechanism: `check_contact` (0x0050f5b0) transition seeding, the
velocity-free `SetPositionInternal` commit (0x00515330), and the live
5%-elasticity landing reflect (DEFAULT_ELASTICITY 0.05 @0x007c6a7c).
Downhill bounce chain, flat-ground pop, and clean uphill landings all
user-accepted ("almost pass with merits"). Investigation + byte-decode
record: `docs/research/2026-07-30-landing-bounce-family.md`.
- **#267 shipped** (vitae/buff panel values; attributes vitae-immune).
**#268 closed 2026-07-31**: the complete
augmentation chain is shared by panel and Runtime movement; AP-127 is
retired. Attributes, secondary attributes, and skills use retail's
vitae-excluded green/red comparison. The selected-skill footer now renders
per-fragment colors through the shared retained text primitive, using the
authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000
debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full
StatMod through dispatch and changes the effective skill immediately. The
user accepted the live colors, values, footer, and immediate row refresh.
- **#269 closed 2026-07-31** — the live 2,184-quantum trace proved the
landing reflect and friction math were correct. ACDream omitted retail's
`OBJECTINFO::kill_velocity` before restoring a remembered contact plane
in `CTransition::validate_transition @ 0x0050AA70`, retaining full
downhill velocity while repeatedly re-grounding the mover. The exact
non-OK-only restore/kill order and final last-known validity overwrite
are now ported, focused/full gates pass, and the user accepted repeated
slope jumps. Evidence:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
- **#271 closed 2026-07-31** — a bounded stair-side
trace proved ACDream could bypass retail's current-position edge back-probe
by promoting a stale `LastWalkable` tread. That made PrecipiceSlide reverse
an uphill tangent and rapidly carry the player down the stairs. The two
stale-history substitutions are removed; current-walkable, back-probe, and
no-walkable outcomes now follow `CTransition::edge_slide @ 0x0050B3D0`.
The exact captured frame is pinned in the existing installed-stair fixture
and the complete Release suite passes 10,062 tests / 5 skips. The user
accepted repeated uphill runs while pressing into the stair sides. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
- **#272 complete and user-accepted 2026-07-31** —
`CACQualities::InqLoad` consumes enchantment-adjusted Strength through
`InqAttribute`, but Runtime movement and both retained burden displays read
raw Strength and did not share the enchantment invalidation edge. They now
consume `GetEffectiveAttribute(Strength)` and
`Spellbook.EnchantmentsChanged`, so buff, dispel, expiration, and death
purge recompute the same burden state immediately. Focused and full
Runtime/App tests pass.
Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing, and #269's slope-stop feel
(rows 3/4/5-partial/12-partial). The 2026-07-31 final session accepted
burdened movement, exhausted jumping, wall/corner response, crowded-monster
movement, two-client remote/door/portal behavior, and shallow water. The user
waived the general sweep, deferred restricted-house validation as #274, and
retained the separate tight-gap clearance mismatch as #273. Automated
scenario 11 remains 20/20 passing. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439
Runtime tests / 0 skips; the complete Release suite passes 10,061 tests /
5 skips / 0 failures.

View file

@ -1,95 +0,0 @@
# Campaign P — final connected visual matrix (runbook)
**The ONE user stop of the physics parity campaign**
(`docs/plans/2026-07-29-physics-parity-campaign.md`). Run after every slice
P1P7 is committed and the full Release suite is green. Each scenario names
its setup, the retail-correct outcome, and the ledger items it closes.
Scenarios 13 need a burden/stamina-capable character on local ACE (use
`@god`-style commands sparingly — see `reference_ace_commands` cautions);
scenario 8 needs the second client.
| # | Scenario | Setup | Retail-correct outcome | Closes / confirms |
|---|---|---|---|---|
| 1 | Burdened movement | Load the character past 100% burden (pack full of heavy loot), then ~190% | Run speed visibly drops past 100%; near 200% the character can barely move and jumps only inches | TS-5/AP-25 retirement (P1) |
| 2 | Exhausted jump | Drain stamina (repeated full-power jumps) to near 0 | Jump cost rises with burden; at insufficient stamina the jump refuses/weakens exactly like retail (no infinite full-height jumps) | TS-5 retirement, ReportExhaustion consumer (P1) |
| 3 | Vitae run | Die once, recover the corpse with vitae active | Run/jump measurably below the no-vitae baseline; recovers as vitae expires | AP-25 replacement (P1) |
| 4 | Cliff edge | Walk (not jump) off a steep cliff/roof edge (Holtburg bluffs) | The body slides along/over the edge (PrecipiceSlide), never a dead stop pinned at the lip | TS-1 retirement (P2) |
| 5 | Downhill sled | Run-jump down a long slope and land | Landing glides ("sleds") with a small bounce, then friction settles it; no instant stick | #166 close, AP-7 gate (P2) |
| 6 | Wall graze | Run into a wall at a very shallow angle; also press into a corner and wiggle | Tiny lateral slide is preserved (no dead-stop absorb); corner shuffle-out works | #116 close (P2) |
| 7 | Crowd regression | Stand in a packed monster camp; wiggle, jump out | Spacing and shuffle-out unchanged from the accepted #182/#184 baseline | P2/P3 regression guard |
| 8 | Two-client remote checks | Second client (retail or acdream) observed from the first | (a) remote stops at walls without sinking in (#165); (b) remote jumping into a dungeon ceiling bounces down immediately (#173); (c) Holtburg town-network portal platform steps up (#172); (d) door Use works after jumping (#174); (e) closed-door collision matches the visual door (#175); (f) no sub-decimeter blips on observed players (#41) | #165 close + the stale #172#175/#41 gate reconciliation |
| 9 | Barred house | Approach a house/cell the character is not a guest of | Blocked at the threshold client-side (no enter-then-server-boot) | AP-71 port (P4) |
| 10 | Wading | Walk into shallow water at a shoreline | Feet sink ~0.1 m into the water surface like retail; movement feel unchanged | AP-10 restore (P4) |
| 11 | Fresh logins ×20 | 20 fresh-process logins (mix of outdoor/indoor saves) | Zero run-on-the-spot; movement immediate every time | #262 close (P6) |
| 12 | General sweep | 10 min free play: walk/run/strafe/turn/jump/stairs/doors/portals incl. one far-town hop | Indistinguishable from the accepted R6 baseline; no new regressions | campaign regression gate; #153 connected confirmation |
Rubber-band check (rides scenario 12): induce a server correction (e.g.
brief packet-loss on Coldeve or a forced position reset) — the leash taper
engages and jumping inside a tight leash is refused (0x47), per the #167
port (P5).
**Recording the result:** per scenario PASS/FAIL + a one-line note. Any FAIL
reopens its slice; the campaign closes only on a clean sheet. On full pass:
close #165/#166/#116/#262 (if not already), mark #172#175/#41 reconciled
with this matrix as the cited gate, update the campaign plan + roadmap +
CLAUDE.md current-state, and flip the goal.
## Automated pillars — BANKED 2026-07-30 (pre-user-session)
Run on the final tree (post all Campaign P slices), local ACE:
- **Lifecycle/reconnect gate: PASS**
`logs/connected-world-gate-20260730-130611/report.json` (seven
checkpoints, graceful exits). Covers scenario 12's login/portal/
teardown backbone.
- **Canonical nine-stop soak: PASS**
`logs/connected-r6-soak-20260730-131141.report.json` (production-
dispatcher movement input across nine stops). Covers scenario 12's
movement-regression backbone.
- **Scenario 11 (20 fresh logins): PASS, automated basis** — 20/20
fresh-process logins (`artifacts/262-probe/login-{1..20}.log`): every
attempt committed the `[snap]` OUTDOOR server-Z branch, held a live
resolve stream (~5.3-6.0k lines/40 s), recentered correctly
(incidentally onto far-town 0xC95B — the #153 arrival class — all 20
times), and closed gracefully. No run-on-the-spot signature. The
user's eyes-on confirmation of "movement immediate" on a couple of
manual logins completes the scenario.
Remaining for the user session: scenarios 1-10 (feel/visual) + the
manual halves of 11-12.
## User matrix session 1 results (2026-07-30, partial)
- Scenario 4/5 (cliff/sled): **FAIL** on the TS-4-removed build — uphill
jump-in bounces (non-retail), roof slides lost, occasional edge wedge.
→ TS-4 removal REVERTED (`2e27d066`+`a8a7d64b`), row re-opened with
live evidence; downhill sled remains #166. Re-test pending.
- Speed parity: **FAIL/SUSPECT** — local char faster than retail
comparison → #266 (controlled capture needed).
- Vitae panel display: **FAIL** (UI, not physics) → #267.
- Squeeze-through at the townhall building: **FAIL** → recorded under
scenario 12; needs a dedicated capture (suspect list: TS-46 sphere-list
threading at a specific site, or the #116 head-sphere change — both
P3/final-slice deltas).
- Other scenarios: not yet reported.
## User matrix session 2 results (2026-07-31)
- Scenario 1 (burdened movement): **PASS**.
- Scenario 2 (exhausted jumping): **PASS**.
- Scenario 6 (wall graze/corner movement): **PASS**.
- Scenario 7 (crowded-monster movement): **PASS**.
- Scenario 8 (two-client remote movement, doors, portals, and its collision
checks): **PASS**.
- Scenario 10 (shallow-water sink-in): **PASS**.
- Scenario 9 (restricted/barred house): **DEFERRED BY USER** and retained as
issue #274.
- Scenario 12 (general movement sweep): **WAIVED BY USER**; the accepted
focused rows and existing automated soak are sufficient for this campaign.
- A separate live mismatch remains: acdream can squeeze through some tight
gaps that block retail. This is outside the accepted wall-graze response
check and is retained as issue #273 pending an exact-location capture.
Together with the previously accepted scenarios 35 and the automated
20-login scenario 11, the Campaign P matrix is closed with #273 and #274 as
explicit carried follow-ups.

View file

@ -1,668 +0,0 @@
# Placement production cutover — campaign plan (2026-08-02)
> ## ✅ CAMPAIGN LEDGER CLOSED — 2026-08-06, by user direction
>
> Every slice is landed and dual-reviewed: **C0C4**, **C5a**, **C5b**
> (#275; retired AP-131 + AD-60's legacy half), **#280** (portal destination
> prefetch, user-accepted at its connected gate), **#276's remainder**,
> **AP-22** and **AD-10** (both retired). **#309** was accepted as a standing
> divergence rather than fixed. Final gate: complete Release suite from a
> clean build, **11,196 passed / 4 skipped / 0 failed** — campaign net +90
> from 11,106.
>
> **The ledger closes with connected gates outstanding, by user direction —
> not because they were discharged.** Only #280's reveal gate was run and
> passed. D-1's two reachability scenarios, AP-136's six-step park protocol,
> route-7 thickening, the two-client observation, the nine-stop soak and the
> lifecycle/reconnect route were **NOT RUN**; the probe family is
> **deliberately NOT stripped** for that reason. Anyone citing "the campaign
> passed" must cite §2.6 of the closeout alongside it:
> [`2026-08-06-c5c-closeout-handoff.md`](../research/2026-08-06-c5c-closeout-handoff.md).
>
> Follow-ups generated and filed rather than folded in: **#325**, **#330**,
> **#331**, **#332**, **AP-149**, **AP-152**, **AD-65**, **AD-66**. Start with
> **#331**.
The final leg of the remaining physics-divergence campaign before AP-22 and
AD-10: route graphical AND headless production placement through the
residence + continuation-executor owner (`38fd4b8d` / `30012361` /
`5db3de3c`), delete the legacy duplicate authorities, and retire AP-1/AD-1
behind connected + user-visual gates.
## Handoff checkpoint — 2026-08-03
**Status: stabilization checkpoint accepted; campaign closeout is not yet
complete.** The C3c production cutover and the O(changed) collision
publication checkpoint are now playable after five separately committed
root-cause fixes:
- `01f4791e` stops origin recenter from manufacturing and replaying a second
retirement receipt for a pending-only live-projection bucket. Its exact
binary passed the complete Release suite, lifecycle route, and canonical
nine-stop soak (`connected-r6-soak-20260802-204309`, nine stops, zero
failures/wait cues/pending retirements).
- `670f307c` keeps remote Create placement, the local-player physics host,
targeting, chasing, and attacks in the same world-coordinate frame. The
user accepted monster placement/chase/hit behavior and static placement
after portals.
- `1fc529cd` materializes the canonical minimal static physics host before a
distant Use/MoveTo route and reconciles the pre-PartArray startup motion
suffix. The user accepted near and distant object use.
- `f24532ad` defers one-shot F754/F755 effects until canonical placement has
bound presentation, retries projectile/static-animation sidecars on the
committed visibility edge, and keeps effect cells synchronized. The user
accepted buffs, recalls, arrows, combat spell projectiles, portals, and
static animation.
- `175ad6b0` sends LoginComplete from the local first-placement terminal edge
instead of raw PlayerCreate receipt, so ACE's intentional login Hidden/
materialization state cannot race placement. The user accepted the login
haze behavior.
Focused verification after the final fix passed 90 App effect/projectile/
static-scheduler tests, two Runtime login tests, the exact live-entity cell
tracking regression, all 79 Headless tests, and the Release solution build
with zero errors. The long connected soak and complete solution suite have
**not** been rerun on the final `175ad6b0` binary. A broader selected fixture
run also exposed five `LiveEntityRuntimeTests` failures tied to the still-open
placement cutover plus one old remote first-entry fixture that supplies an
empty collision source; classify and fix those before claiming C5 closure.
**Resolved 2026-08-03 as #281 (DONE):** the "six selected fixture failures"
figure was itself a mis-measurement — the measured baseline found **43**
(28 App broken by `670f307c`, 2 more by `f24532ad`, 13 Runtime) — repaired
without weakening assertions (`6dcb94ac`, `98e9f9e8` and the recent-regression
cleanup closed at `2ef02f8c`); every later checkpoint's complete suite ran
0-failed.
Remaining campaign work, in order:
1. Reproduce and repair the six fixture failures without weakening their
assertions or adding compatibility bypasses. **DONE 2026-08-03 (#281
the real count was 43; see the correction above).**
2. Finish C4's routes 27 and remove their legacy placement writers; fold in
#276 and #277 where their route becomes authoritative. **DONE 2026-08-05
except the four owed connected gates (see the C4 slice below). #276 was
folded only PARTIALLY — route 5 closed its projectile half; the
`SpawnPlacementSettler` settle-cell discard remains OPEN. #277 was NOT
folded: no streaming/broadcast radius changed, so its service-window
conversion remains a trigger-conditioned carry, not a completed item.**
3. Resolve #280 with retail's configured destination-prefetch window so the
portal viewport never reveals visibly constructing far terrain.
**DONE 2026-08-05 (implementation + suite); the connected/visual gate is
batched into C5's matrix. Shape correction: retail has NO separate prefetch
window** — it has one landscape square (`LScape::mid_radius`) that is
simultaneously the loaded, drawn and blocked-on set, and whose configured
value is `Render.LandscapeDrawDistance`. acdream now derives its reveal
window from the live streaming radii (`QualitySettings.FarRadius`) and made
the render-completeness predicate tier-aware so the outer rings can satisfy
it. Contract: [`2026-08-05-280-contract.md`](../research/2026-08-05-280-contract.md).
Residual filed as AP-149; the missing user-facing Viewing Distance option is
filed separately as #326 and is explicitly NOT part of #280.
4. Run C5's complete Release suite, lifecycle/reconnect route, latest-binary
nine-stop soak, two-client observation, and the remaining #269 slope-glide
visual check. A pass from `01f4791e` is evidence for that fix, not a
substitute for the final-binary soak. **Correction 2026-08-05: #269 was
already closed and user-accepted 2026-07-31 (before this plan was
written); the surviving visual item is #278(b)'s lateral-glide
comparison, not #269.**
5. Delete the superseded paths, retire AP-1/AD-1/AP-131 and AD-60's legacy
half only when the code proves they are gone, then complete AP-22 and
AD-10 and close the campaign ledger.
**DONE except the ledger close, 2026-08-05/06.** AP-1/AD-1 retired at C5a
(`6921a027`); AP-131 and AD-60's legacy half at C5b (`735f0a72`); **AP-22**
retired at `bc4679cd` (all three invented-cylinder copies deleted — the row
listed one; reachability proved zero over all 5,935 installed Setups by four
independent decoders); **AD-10** retired by deletion at `886333a2` (its
stated justification was false at HEAD — remotes DO run the sweep, so the
projection was an extra non-retail layer, measured bit-identical when
removed). Both dual-reviewed, both lenses PASS. Remaining: C5c's gates and
the ledger close. Two new divergences were filed out of AD-10's work
(AD-65, AD-66) and two issues (#331 uphill-resolve blockage, #332 headless
remote dead-reckoning).
**Inputs (read in order):**
1. [`2026-08-02-runtime-continuation-executor-handoff.md`](../research/2026-08-02-runtime-continuation-executor-handoff.md)
— the completed dormant mechanism and its cutover notes.
2. [`2026-08-02-cutover-route-inventory.md`](../research/2026-08-02-cutover-route-inventory.md)
— the full 8-route, both-host call-chain inventory with exact file:line
for every duplicate authority to remove. THE map for all slices below.
3. [`2026-07-31-remaining-physics-campaign-handoff.md`](../research/2026-07-31-remaining-physics-campaign-handoff.md)
— the original per-route requirements and prerequisite definitions.
**Standing discipline per slice:** pinned contract → single implementer →
independent retail-conformance + architecture/adversarial reviews (both must
PASS on the final diff) → focused + complete Runtime + Release build +
complete solution gates → bisectable behavior commit (register rows in the
same commit) → docs/handoff commit. No workarounds; no fused slices.
## Confirmed pre-cutover gaps (from the inventory)
- The executor publishes only generic entity deltas; nothing bridges its
completion to `RuntimePlacementProjectionChannel`, so no host can learn
"my initial placement committed" through the built observer seam.
- No atomic controller/body publication owner exists (prerequisite C);
App and headless hand-write divergent `PlayerMovementController`
construction, and `SubmitPreparedPlacement` requires a canonical
`PhysicsBody` that nothing currently publishes atomically.
- The dormant placement path's 1,880 B/operation (2,048 cap) allocation
remains the activation blocker for frame-frequency routes.
- `Execute`'s live inputs (`UsePositionFromServer`, `PlayerDistance`) are
computed by no host; they must derive from Runtime's own character-option
and local-player owners.
- `RuntimePortalPlacementAuthority` has zero producing call sites; the
adapter from `RuntimeWorldTransitState` does not exist.
**Corrected 2026-08-04 (C4 route 3 closure,
`docs/research/2026-08-04-c4-route-3-contract.md`), itself corrected
2026-08-05 (A10 architecture review — the first correction asserted a
false fact of its own), and rewritten 2026-08-05 (N5 retail-review
round-3 fix — the prior wording of this correction contradicted
itself).** The original bullet conflated two separate claims into one
sentence, and only one of them was true. What pre-dated route 3 and WAS
accurate: the `RuntimePortalPlacementAuthority` type existed (referenced
by route 2's `Pending.Portal` field, always `Present: false`), its
`IsValid` check existed, and the sinks' portal-authority gates plus
`BeginAcceptedPlacementCore`'s gate already read it. What was NOT
accurate, and is what "zero producing call sites; the adapter does not
exist" actually described: the PRODUCER half — nothing built a
`Present: true` authority and called the consumer arm
(`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival`/
`SubmitAndResolvePortal`/`ClassifyPortalArrival`) — that consumer arm
ALSO did not exist before route 3. Route 3 added the producer and the
consumer together, in the same slice: the producer is
`LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement` (now
`TryAdvancePortalCommit`/`TryExecuteCanonicalPortalPlacementCore`, per the
2026-08-05 A1 review fix), which builds the authority from
`WorldRevealCoordinator`/`RuntimeWorldTransitState` facts and calls
`TryExecuteAcceptedPortalArrival`; the identical Runtime entry point is
shared by the headless host. So: the type/`IsValid`/consumer-gate facts
pre-dated route 3 and were true before it; the arm (both the producer
that builds a live authority and the consumer that reads one) did not
exist before route 3 and is what the original bullet's "zero producing
call sites" language was pointing at.
- The exact-Setup mover chain (`PrepareMover` /
`RuntimeSetPositionMoverPreparer.TryBuild` /
`IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired.
- **Corrected 2026-08-04 (C4 route 6 closure,
`docs/research/2026-08-04-c4-route-6-contract.md`): all three clauses
above were stale.** Route-6 split-recovery does NOT need an effect-replay
suppression signal — that premise was unsubstantiated; acdream's only
create-time effect replay is the F754/F755 queue drain keyed by server
GUID, and the one plausible mechanism (a cloned `DefaultScriptType`
surviving `BuildSpawn`) never fires at create in either client
(`CPhysicsObj::play_default_script @0x005132B0`/`@0x00513300` has exactly
two callers, both animation hooks, verified against
`acclient_2013_pseudo_c.txt`). Route-7's `TryCommitParent`/
`CommitWithdrawal` cancellation-symmetry fixes and host-visible
cancellation receipts were BOTH closed at C0 (see the C0 slice below).
What actually remained for route 7: the child's canonical cell had two
writers (Runtime committed it cell-less unconditionally in
`CommitAcceptedParentCellless`, while `EquippedChildRenderController
.TickChild` re-celled it from a per-frame render tick), and headless had no
`EquippedChildRenderController` at all, so every headless parented child
stayed cell-less forever — the same defect seen from two sides, not two
separate gaps. **Closed 2026-08-04
(`docs/research/2026-08-04-c4-route-7-contract.md`).** Runtime is now the
sole canonical writer: `CommitAcceptedParentCellless` completes retail
`set_parent`'s attach-time re-cell (D1), and every canonical cell write
funnels through one directory chokepoint that recursively propagates to
committed children on every parent cell crossing (D2 —
`docs/research/2026-08-04-retail-parent-cell-propagation.md`), not only at
attach. `TickChild` is demoted to a presentation-only draw-bucket move
(D4); the headless host gained its own parent-realize drive running the
same commit pair the graphical host does (D5,
`RuntimeLiveEntitySessionController.OnParentUpdated`). The direct headless
regression test (a bot with an equipped item shows the child's canonical
`FullCellId` equal to the parent's) now passes.
## Slices
- **C0 — Runtime bridge + live inputs — COMPLETE at `67f63e85`
(2026-08-02, dual reviews PASS).** The executor publishes an
acknowledge-only `ExecutorCompleted` receipt through the one placement
stream (registered before dispatch; correlation reaped on
acknowledgement/discard/clear; `PendingCompletionReceiptCount` in
`IsConverged`); all three production sinks acknowledge-and-ignore the
kind via early returns proven behavior-preserving for every other kind
(sanctioned seam completion — provably inert, no production publisher);
`UsePositionFromServer` derives retail-exactly from
`RuntimeCharacterState.AutonomyLevel != 2` and `PlayerDistance` from the
live movement controller with null-safe fallback to the caller struct;
`TryPrepareAndSubmitAuthoredPlacement` chains the prepared-collision
Setup read through `PrepareMover` to submission with zero validation
changes; `TryCommitParent`/`CommitWithdrawal` gained the sibling
cancellation flow (the `LeaveWorld` omission in `TryCommitParent` is
retail-REQUIRED per `set_parent` 0x00515A90:283832-283833's single gated
`leave_world`). Not fully dormant by design: the two cancellation fixes
change live Runtime paths production already calls; everything else has
no production caller.
**C3 prerequisites recorded from C0's reviews:** (a) the completion
receipt/trace surface is internal-only — C3 must define the public host
consumption shape when it wires the hosts; (b) `PlayerDistance` is
resolved once per `Execute` entry, not per continuation — a multi-Position
FIFO classifies later entries against entry-time distance (documented
deferral; refine at C3/C4 if the connected gates show it matters);
(c) any future host exposure of `TrySetAutonomyLevel` must carry retail's
`SendAutonomyLevelEvent` (699550).
- **C1 — atomic controller/body publication — SATISFIED BY EXISTING
MECHANISM (research finding 2026-08-02, plan amended same session).**
`RuntimeLocalPlayerPhysicsPublicationState` (1,033 lines) plus the
~15-method dormant local-activation family on `RuntimeSetPositionState`
already implement the full sanctioned option-2 transaction:
off-canonical preparation against a scratch quantum clock and a sealed
candidate controller, one validated atomic Commit, and a staged
Evaluate/Commit/FinalizeActivation chain re-validated against
PhysicsOwnershipEpoch/ObjectClockEpoch/ControllerOwnershipEpoch/session
identity at every entry — with zero production callers. See
[`2026-08-02-canonical-body-writer-map.md`](../research/2026-08-02-canonical-body-writer-map.md)
(6 canonical body writers; the two host escape hatches; both hosts'
divergences). The remaining work — routing both hosts' local-player
construction through the publication lifecycle, sealing the public
`RuntimeLocalPlayerMovementState.Controller` setter, retiring App's
direct object-clock bypasses, and containing headless's uncaught
prepared-collision `InvalidDataException` — IS the C3 route-1 flip and
moves there. No separate C1 commit.
- **C2 — placement allocation budget — COMPLETE at `63c601ff`
(2026-08-02, dual reviews PASS after two fix rounds).** 2,032 → 944
B/op via pooled operation envelopes (bounded, reset-at-rent, double-
retire guarded, reset/dispose-cleared, ledger-visible), a cached
collision-callback delegate over an explicit context stack, and a
non-boxing pending-head read; gate tightened to 1,536. The pooling
forced a class-wide staleness rework: captured-token-vs-fresh-lookup at
every reentrancy-spanning frame (26-site audit), hoisted stack locals
for retail's handle_all_collisions bits, token-gated bookkeeping
writes, and a deliberately identity-agnostic settle path (retail's
SetPositionInternal completes unconditionally even for displaced
operations).
**Residual floor (documented at the gate, decision deferred to the C3
activation gate where the user is in the loop):** ~520 B/op inside
Core's `PhysicsEngine.SetPosition` (transition init / query-footprint
materialization — a potential C2b if C3's connected profile shows it
matters) and ~208 B/op of sorted-tree node per pending receipt.
**Maintenance notes from review (no action):** the no-reentrancy
proofs on the 15 surviving reference-based currency checks are
comment-enforced; `IsCurrent(Operation)` remains available and a new
reentrancy-spanning call site would silently inherit the tautology —
its doc comment warns.
- **C3 — spawn-frequency cutover: routes 1 + 8 — DECOMPOSED 2026-08-02
after the first implementation pass stopped with findings.** C3-1 (the
public executor-completion surface via
`RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion`)
landed separately. Two structural gaps halted the flip, both real and
neither in the planning docs:
**(B)** the local player's residence lease opens its SetPosition
operation at Create time, but `SubmitPreparedPlacementCore` requires a
pre-existing canonical body that only the zero-caller publication chain
can attach — first-entry needs an explicit resumable sequence
(begin-placement → publication Prepare/Commit attaches the body →
authored-mover submit → Place receipt → Execute), which matches the
campaign handoff's route-1 required order but exists nowhere as a
driveable state machine;
**(C)** ordinary remote-creature Creates classify to `SetPosition` but
have NO production body-construction path at Create time (bodies arrive
with first motion today; retail constructs physics at CreateObject via
`ACCObjectMaint::CreateObject`/`set_description`, which our retail
notes fully document — the defaults come from the wire PhysicsDesc,
not invention).
Sub-slices, each with the standing contract/dual-review/gate
discipline:
- **C3a — Runtime first-entry sequencing — COMPLETE at `960373df`
(2026-08-02, dual reviews PASS).** `RuntimeLocalPlayerFirstEntryState`:
five stages (mover-prep → publication Prepare/Commit → activation →
acknowledgement → Execute) in retail's own order — mover shapes
BEFORE placement, matching makeObject/set_description preceding
enter_world; the original contract prose had it backwards and the
tested preconditions forced the faithful order. Acknowledge-stage
authority discrimination, automatic convergence through the (now
multicast, snapshot-iterated) retirement fan-out, ownership-ledger
fold, transactional late-bind Publication seam. Dormant: C3c's first
act is the GameRuntime binding + production Advance drive.
**Carried findings for C3c:** the controller is live from the
activation commit onward (abandonment leaves it to ordinary entity
teardown — retail has no entry-flow rollback); EvaluateActivation's
post-commit DeferredCell overload is encapsulated behind Advance.
- **C3b — remote body construction at Create — COMPLETE at `0934a121`
(2026-08-02, dual reviews PASS).** `RuntimeRemoteBodyDescription` +
`RuntimeRemoteFirstEntryState`: the full `set_description` order with
the byte-certain gates (friction [0,1] inclusive, NaN sanctioned-skip;
elasticity clamp with retail's unordered-to-zero; translucency
!= 0.0f), the movement-branch discriminator on retail's
`movement_buffer != 0` (empty-buffer → placement branch, no autonomy),
motion-table zero-id pass, ctor-defaults for absent wire fields, and
never-clobber coexistence with the build-at-first-motion production
path. The acknowledge discriminator is one shared body
(`RuntimeFirstEntryAcknowledgement`) for both conductors. Dormant.
- **C3c — the host flips (production) — COMPLETE at `529e0e9d`
(2026-08-02, dual Opus reviews: initial FAIL 2+2 MAJOR → R1 fix
round → delta PASS both).** Both hosts register initial Creates
through residence + conductors via the shared
`RuntimeFirstEntryDriveController`; Controller setter sealed;
rebucketing presentation-only strictly while the residence is
ACTIVE (post-residence entities take the full legacy path including
the `prepare_to_enter_world` clock edges); content-less headless
keeps pre-flip direct registration. Five fix slices landed inside
the cutover, each connected-gated: F1 (Runtime ownership seam for
movement stats/server physics — the post-logout retired-controller
crash), F2 (the login activation wedge: admission-prefix gate
factored from the seal, rearm generation identity, auto-entry
requires the published controller), F3 (landblock-prefix 0-sentinel
→ explicit absent-id; corner landblocks legal), F4 (diagnosis only:
the nine-stop soak's convergence failure is pre-existing `6b28ff99`
whole-world collision-clone throughput — its fix is the next slice
before C5), F5 (local-player first-entry ground contact via the
shared `SpawnPlacementSettler` at `FinalizeActivation`; the
standing-cast airborne rejections are gone; register AD-61). R1
additionally armed the login constraint leash at the committed
placement (`HandleReceivedPosition` 0x00453FD0 analog) and
refreshed AD-42. Final gates: complete solution 10,816/0/4 skips;
lifecycle/reconnect gate PASS (`connected-world-gate-20260802-
175401`). Closeout:
[`2026-08-02-c3c-cutover-closeout.md`](../research/2026-08-02-c3c-cutover-closeout.md).
**Carried to C4/C5:** route-1 far-Create service-window conversion
if either streaming/broadcast radius changes (#277); the
window-departure park narrowing; `NotifyRetirement`-on-active-entry
subscriber invariant; the reachable equip-mid-conductor fail-fast;
settle-CellId discard (#276-adjacent, see ISSUES).
- **C4 — remaining routes: 2 (ForcePosition), 3 (portal, with the
`RuntimeWorldTransitState``RuntimePortalPlacementAuthority` adapter),
4 (remote Create/Position; delete `RemoteTeleportController`/`Placement`
and the inline MoveOrTeleport duplicate), 5 (projectile authoritative),
6 (drops + split-recovery marking), 7 (residual pickup/parent/delete
polish). — route 2 COMPLETE AND USER-ACCEPTED 2026-08-03 (`9966b531`);
routes 3/4/5/6/7 remain OPEN.**
**C4 IMPLEMENTATION COMPLETE 2026-08-05.** Every route now places through
the canonical Runtime owner; the campaign's remaining C4 debt is exactly
the four owed connected gates listed at the end of this bullet. Per-route
record (each with contract + independent dual reviews per the standing
discipline; suite counts measured, never inherited — final complete
Release suite **11,090 passed / 4 skipped / 0 failed** at `e0f96a55`):
- **4a LANDED `44830a0e`; 4b-1 LANDED `2e8e09ac` (dormant
infrastructure); 4b-2 LANDED `7f1c1f5a`** (recorded in the sub-bullets
below with its four fix rounds and user-passed far-snap gate).
- **4b-3 LANDED `6dc7ba51` (2026-08-04)** — remote teleport + cell-less
through the canonical placement; `RemoteTeleportController` (605 lines),
`RemoteTeleportPlacement` (85), and ~1,709 test lines deleted. Dual
round 1 FAIL/FAIL → round 2 delta PASS/PASS; three NPC-arm MAJORs
closed. **Connected gate PASSED-partial (`21cd6e9b`)**: 16
`[remote-teleport]` probe lines over 7 creatures, all
`cause=teleport-ts``cause=cellless` was never observed and remains
test-covered only (owed gate 4 below). Docs at `8c269ad1`; findings
chain in `2026-08-04-c4-route-4b-3-*.md`.
- **Route 5 LANDED `36255af0` (2026-08-04)** — projectile authoritative
placement (#276 partial), preceded by a mandatory byte-decode gate
(`MoveOrTeleport` @0x00516330 never reads its velocity argument, which
also spawned #317). Three dual review rounds closing 8 MAJORs; round 3
retail PASS with the AP-141 risk-column retraction (C1), architecture
FAIL on a coverage-only C1 closed in-commit with two sabotage-verified
retry-arm tests. **NO connected gate exists for this route, by
design** — ACE never sends a missile UpdatePosition
(`WorldObject_Tick.cs:333-334`); every proof is deterministic-test-gated
and recorded as such. Interim landings alongside: the OnPosition
dual-tail collapse (`edc911b0`, whose scoping found and filed #316),
#315 closed (`aaf0811f`), #314 closed (`daef7c98`).
- **Route 6 CLOSED `1b484937` (2026-08-04) with ZERO production lines**
C3c had already flipped both drop flavours onto the canonical create
transaction; the landing is 7 sabotage-verified coverage tests, the
retail split-marking record (#313 filed for the `DeclareValid`
selection transfer), and the correction of this plan's own false
effect-replay premise (see the corrected gap list above). Its coverage
tests immediately found #314 (split recovery threw on retained
timestamps), fixed in its own commit `daef7c98`. **Connected gate owed**
(drops recipe — owed gate 1 below).
- **Route 7 LANDED `cd3129e9` (2026-08-04)** — child cell propagation
moved from a render tick into Runtime: retail `set_parent`'s attach-time
re-cell completed in `CommitAcceptedParentCellless`, the recursive
parent-cell-crossing propagation at the one directory funnel (iterative
worklist — the initial depth-64 cap was deleted after both round-2
reviews independently found its truncation residue was the #184 shape),
`TickChild` demoted to presentation-only, the headless parent-realize
drive added (its direct regression test failed before this work), and
the dead `ClassifyLeaveWorld` family deleted. Dual round 1 FAIL/FAIL →
round 2 delta PASS/PASS plus a coordinator-required third pass; 5
MAJORs. AP-142/AP-143 filed. **Connected gate owed** (equip/carry with
`cause=propagate` probe evidence — owed gate 2 below). Route 7 also
INVALIDATED 4b-3's recorded cell-less live recipe (contract §11; the
supersession note is appended to the 4b-3 contract).
- **Route 3 LANDED `e0f96a55` (2026-08-05)** — the LAST route: the first
production `RuntimePortalPlacementAuthority` producer, the portal arm on
route 2's drive controller, `CommitCanonicalTeleportFrame` with the
`PlayerTeleported` port (autorun cancel + one movement event), and both
duplicate authorities deleted (`LocalPlayerTeleportPlacement.Place`,
`ResynchronizeLocalPlayerForPortalArrival` — AD-42's row deleted with
them). Contract at `19ebf043`; scoping/propagation research at
`ca96ea5e`. Dual round 1 FAIL/FAIL → dual round 2 FAIL/FAIL (near miss)
→ round-3 fix pass accepted per both round-2 reviews' explicit pass
conditions; the round-3 record is the commit message plus #318 and
AP-144/AP-145 (no standalone round-3 review doc). The fix pass's
refusal to accept 7 skipped tests uncovered a real production bug (the
canonical portal arm was 100% dead code — the accepted-destination slot
it re-read at Place time was already consumed at Aim time). **Connected
gate owed** (portal/recall with `[local-tp]` probe evidence — owed gate
3 below — and explicitly NOT scored as covering #318).
- **The four owed connected gates**, with recipes and pass criteria in
[`2026-08-05-c4-closeout-handoff.md`](../research/2026-08-05-c4-closeout-handoff.md):
(1) route 6 drops; (2) route 7 equip/carry across landblock boundaries,
counted only with `[child-cell]` `cause=propagate` lines; (3) route 3
portal/recall, counted only with `[local-tp]` lines, not scored against
#318; (4) 4b-3's `cause=cellless` case, whose recorded trigger route 7
invalidated — the replacement provocation is UNESTABLISHED and needs its
own investigation. None has been run.
**Route 4 SPLIT into 4a and 4b (user-directed 2026-08-03).** Scoping
([`2026-08-03-c4-route-4-scoping.md`](../research/2026-08-03-c4-route-4-scoping.md))
put whole-route 4 at 1,500-2,500 production lines against a stated ~400
budget, so it is split to keep each landing reviewable:
- **4a — the steady state.** The classifier's `Interpolate` (contact,
`PlayerDistance < 96 m`) and `NoPositionOperation` (no contact) branches.
NEITHER runs a `SetPosition`, so 4a has no deferred-cell park, no
service-window work, and no placement-allocation exposure. Fixes two of the
three unfiled divergences (the NPC airborne hard-snap that ignores the wire
`IsGrounded` bit; `ConstrainTo` armed before the operation instead of
after). Highest visible value — this is what makes creatures move smoothly.
- **4b — the edges.** `SetPosition` / `SetPositionSimple`: teleport, far-snap
(>= 96 m), and cell-less first placement. This is where the parks, the
Position-time service-window guard, #277's broken bound, N3 (headless never
calls `RetryPending`), and the third divergence (`ConstrainTo` never armed
on the remote teleport branch) all live.
**4b also inherits 4a's ownership remainder — scheduled here, not implied
by code comments.** Two independent reviews flagged that 4a satisfies
contract items 1 and 2 only partially, and the plan must carry that rather
than leaving it in `// 4b deletes this fallback` comments:
- Runtime owns the classification, the request construction (one shared
builder, `RuntimeAcceptedPositionRouteRequests`), the near-InterpolateTo
decision with AP-87, and the post-operation `ConstrainTo`. **App still
owns** branch selection, the airborne early return, the
`RemoteMotion.CellId` write, the `WorldEntity` pose write, and the
collision-shadow publish — all in `LiveEntityNetworkUpdateController`.
- Item 2 ("both hosts drive the identical Runtime entry point") is
satisfied only VACUOUSLY: `RuntimeLiveEntitySessionController` returns
early for remotes, so no no-window host exercises this path at all and
nothing can diverge yet. That stops being true the moment a headless
host needs remote motion.
- Every legacy fallback 4a deliberately left in place is 4b's to delete:
the pre-operation unconditional `ConstrainTo`, the player arm's
`!update.IsGrounded` no-op, the player and NPC legacy near/far routing
(each still carrying its own duplicate `96f` / `4f` constants), and the
airborne-precedence carve-out
(`LiveEntityNetworkUpdateController.ApplyRemoteContactRouting`) that
keeps a landing body snapping. Retiring the last one is a real behaviour
decision — retail makes no player/NPC distinction there — and needs its
own live evidence, not a silent convergence.
- Register row **AP-135** (the airborne no-op's retained acdream
bookkeeping: the server cell id for the free-fall sweep gate, and the
last-server-position sample) — **CORRECTED 2026-08-04: this row does NOT
retire with 4b.** Its own stated condition is retirement together with the
free-fall sweep gate (`RuntimeRemotePhysicsUpdater.cs:342`), which 4b does
not touch, and its sites are the airborne no-op branches — 4a-owned
dispositions, not 4b's far-snap/teleport/cell-less. The trap is that those
two writes sit physically inside `OnPosition`, which 4b rewrites heavily,
so an implementer will assume they go. They stay. See
[`2026-08-04-c4-route-4b-scoping-and-split.md`](../research/2026-08-04-c4-route-4b-scoping-and-split.md).
- **4b is itself split into 4b-1 / 4b-2 / 4b-3** (2026-08-04). Scoping put
4b at 1,300-2,200 production lines — 4-6x route 4a — plus ~2,500-3,500
lines of test work. 4b-1 is infrastructure with no remote behaviour change
(the per-entity placement owner, the service-window guard, the
refuse-rather-than-park policy, N3's headless `RetryPending` pump); 4b-2 is
the far branch alone; 4b-3 is teleport/cell-less and the ~739-line class
deletions. 4b-1 stays a separate landing regardless: it is where the
park-withdraws-the-entity failure mode is decided, and it must not be
reviewed alongside a large deletion.
- **4b-2 LANDED at `7f1c1f5a` (2026-08-04); far-snap connected gate
USER-PASSED same day.** Four fix rounds, eight Opus reviews; the slice was
fully green at 10,990 / 10,997 / 11,004 while containing real defects
(a frozen remote pinned as correct by its own test; a fallback that
over-wrote on the exact retail paths that decline to store; a park guard
incomplete on two independent axes). Final suite 11,009 / 4 / 0 against a
**measured** 10,968 baseline — the 10,973 figure used earlier was wrong.
Its real yield was a defect under routes 1 and 2, not the far snap:
`ParkDeferred`'s quiescence parks withdrew the entity and were never
restorable while `Forget(restoreCancelledPark: true)` runs for every
accepted Position on every entity. The restorable decision now lives
inside `ParkDeferred` after `SnapToCell`, read against every live
quiescence.
**Still outstanding: #309.** The `ACDREAM_PROBE_PARK=1` capture from the
accepting session shows 11 parks, all `cause=unplaceable` — zero
quiescence-cause parks, so the shared-core park change is NOT yet
connected-verified. Without the probe that session would have been
recorded as a pass. **Re-scoped 2026-08-04: #309 is largely superseded
by #312 (closed `b1f914d5`, user-passed); what survives is the narrow
`GotoLostCell` half — retail keeps a lost-cell object hidden until
`reenter_visibility`; acdream re-shows it on cancel. Re-scope before
running it.**
Process lesson recorded: the round-1 defect was caused by the contract
omitting "and still advance the pose", and the park defect should have
been split into its own slice the moment it surfaced in round 2 instead
of riding inside 4b-2 for three more rounds.
Findings chain:
[contract](../research/2026-08-04-c4-route-4b-2-contract.md) →
[round 1](../research/2026-08-04-c4-route-4b-2-review-findings.md) →
[round 2](../research/2026-08-04-c4-route-4b-2-delta-review-findings.md) →
[round 3](../research/2026-08-04-c4-route-4b-2-round3-correction.md) →
[round 4](../research/2026-08-04-c4-route-4b-2-round4-correction.md).
Note the route-4 Create half is ALREADY DONE (C3b/C3c); the remaining work is
steady-state remote Position plus the deletions. AP-131 is NOT retired by
either sub-slice — see the scoping doc for why route 4 alone cannot.
4a contract: [`2026-08-03-c4-route-4a-contract.md`](../research/2026-08-03-c4-route-4a-contract.md).
**Route 2 connected gate PASSED (user, 2026-08-03).** Provoked with the
retail `@pklite` entry-collision bump (`69ba9486` — the only reachable ACE
trigger for `ObjectForcePosition`; admin teleports advance `ObjectTeleport`
and exercise route 3 instead, see
[`2026-08-03-c4-route-2-visual-gate.md`](../research/2026-08-03-c4-route-2-visual-gate.md)).
The user observed the visible slide off the overlapped character (the
ForcePosition applied), correct animation, no heading change, and no leash
tethering or rubber-band after the correction — so the two named behaviour
changes (ack after commit; no `ConstrainTo` re-arm on this route) are
accepted live. Both Opus reviews PASS on the final diff after three FAIL
rounds.
**Adjacent, NOT a route 2 regression:** shipping `@pklite` made PK Lite
reachable for the first time and immediately exposed pre-existing PvP gaps —
melee/ranged attacks refuse a PKLite target (auto-target retargets to the
nearest other; auto-target off does nothing) while spells on the same target
work. Under investigation; filed separately.
**Route 2 (ForcePosition) — implemented 2026-08-03, contract:**
[`2026-08-03-c4-route-2-contract.md`](../research/2026-08-03-c4-route-2-contract.md),
**plan:** [`2026-08-03-c4-route-2-implementation-plan.md`](../research/2026-08-03-c4-route-2-implementation-plan.md).
`RuntimeAcceptedPositionDriveController`
(`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs`)
is the single accepted-Position execution seam for a ForcePosition on the
already-live local player; `LocalForcePositionTransaction` and
`HeadlessSessionWorldProjection.BlipLocalPlayer` are deleted, and the
generic App render-tail is skipped for the local player's ForcePosition.
Named behaviour changes (both retail-exact, ISSUES #285): the outbound
ack now fires strictly after the canonical commit, and the constraint
leash is no longer re-armed on this route (retail's FORCE_POSITION branch
never reaches `ConstrainTo`).
**Fix round (2026-08-03):** both independent dual reviews (retail-
conformance + architecture/adversarial) FAILed the first pass — see
[`2026-08-03-c4-route-2-review-findings.md`](../research/2026-08-03-c4-route-2-review-findings.md)
for the full R1-R9 list. The critical finding (R1) was that the
DeferredCell park could not survive a single ACE broadcast interval in
production (`RuntimeEntityObjectLifetime.TryApplyPosition`'s unconditional
`Forget` on every accepted Position cancelled it before its collision
generation could commit), silently dropping the correction forever;
`RuntimeAcceptedPositionDriveController.Advance` now detects the dead
watch and re-issues from the entity's current canonical snapshot. R2/R3
restored headless's collision re-centering and login-window fallback; R4
stopped the force-ack from stealing a receipt the presentation sink had
legitimately declined; R5/R6/R9 corrected false doc claims, closed a
`_pending`-leak/overwrite gap, and fixed streaming-observer/pose-dirty
side effects firing on a declined placement. R7 corrected a fixture bug
(a dummy Setup sphere with its centre at the origin) that had been
written up as a retail fidelity gain; R8 added App-layer double-write
source pins and corrected an overclaimed single-ack test. Full detail:
[`2026-08-03-c4-route-2-review-findings.md`](../research/2026-08-03-c4-route-2-review-findings.md).
Complete Release solution after the fix round: **10,853 passed / 4
skipped / 0 failed** (baseline 10,844/4/0; first pass 10,848/4/0).
**Acceptance item 2 is NOT met — recorded gap, B2 (2026-08-03 round 2).**
An earlier revision of this paragraph claimed R8 "added the App-layer
double-write source pins the plan's own acceptance item required". That was
a claim of coverage this changeset does not have, and it is corrected here
rather than left as the citation a future session trusts (same rule that
produced R7). The truth, per the adversarial review:
- *First half — "the generic tail no longer double-writes the local
player":* **source-pinned, not proven.** The pin is a regex/`Assert.Single`
over `LiveEntityNetworkUpdateController`'s source text, so it would still
pass if a second write were spelled differently, and **no test exercises
the branch** at runtime.
- *Second half — "the committed projection is what moves the render
entity":* **uncovered at any layer.** No test drives a route-2
ForcePosition through `RuntimePlacementPresentationSink` /
`TryApplyRuntimePlacementPlace` and asserts the `WorldEntity` actually
moved. Given R4 (the force-ack no longer consumes a declined `Place`),
this is precisely the seam whose failure mode is silent: the canonical
body moves and the render entity stays put.
Closing this gap needs an App-layer test that runs the accepted
ForcePosition end to end and asserts the render entity's position/cell came
from the committed placement receipt — carry it into C5's parity tests or
file it before this sub-landing closes.
**Not yet done:** both reviews must be RE-RUN on this fixed diff, and the
connected (user-gated) acceptance gate this campaign's standing
discipline requires, before this sub-landing is considered closed — those,
and the commit itself, are next. May land as more than one commit if a
route proves large; each sub-landing keeps the full review discipline.
- **C5 — legacy deletion + closeout gates — OPEN.** Delete every superseded legacy
path; parity tests; exact lifecycle/reconnect + canonical nine-stop
connected routes; two-client observation; **user visual matrix** (the
campaign's stopping point for user acceptance). Retire AP-1, AD-1,
AP-131, AD-60's legacy half, and close #275. Update register/roadmap/
milestones/architecture/memory + successor handoff.
**Inheritance recorded at C4 closeout (2026-08-05, full detail in
[`2026-08-05-c4-closeout-handoff.md`](../research/2026-08-05-c4-closeout-handoff.md)):**
the #318 end-to-end portal composition test, whose discriminating
assertion is that **`PhysicsEngine.ShadowObjects` holds a row at the
destination — not just `LocalPlayerShadowState`'s dedup cache** (AP-145's
cache-without-publish asymmetry is why a cache-only assertion is satisfied
by the bug); the route-3 C5 sweep candidates (`ILocalPlayerTeleportPlacement`
as a thin acknowledge seam; the test-only `BeginAcceptedPlacement`/
`BeginAuthoredPlacement` wrappers); #276's settle-cell remainder and
#277's trigger-conditioned conversion; #316's measure-before-fix, #317's
velocity-chain audit, #313, and #309's re-scoped narrow half; the
cell-less live-trigger investigation (owed gate 4); and the TEMPORARY
physics probe family strip (`REMOTE_LANDING`/`REMOTE_SLIDE`/`PARK`/
`REMOTE_TELEPORT`/`CHILD_CELL`/`LOCAL_TELEPORT`) — after, never before,
the four owed gates consume them.
**#280's connected gate rides this matrix (added 2026-08-05).** Release,
`ACDREAM_RETAIL_UI=1`, `ACDREAM_STREAM_RADIUS` **UNSET** (it forces
`NearRadius` and only raises `FarRadius`, so a run with it set measures a
different window than production). Run the route TWICE on the same binary —
once with `ACDREAM_PROBE_REVEAL_RADIUS=1` (reproduces the pre-#280 gate) and
once without — and report BOTH. The user-facing observable is an ABSENCE, so
the pass criteria are three positive artifacts per stop, all from existing
machinery: (1) a `world-visible` checkpoint JSON whose
`StreamingWork.NearBacklog` / `.FarBacklog` / `.DestinationBacklog` /
`.PendingPublications` are zero for the destination window at the moment the
viewport opened; (2) a hold-duration pair — **the post-fix hold is EXPECTED
to be LONGER**, and a hold that is not longer means the gate did not widen
and the run proves nothing; (3) a paired screenshot per stop, where the
pre-fix run is the one that shows the defect. `wait world-visible 30000` in
`tools/connected-world-lifecycle.route.txt` is the convergence ceiling — a
trip is a failure, a longer pass is not. **The reported repro was a RECALL,
not `/teleloc`: the matrix needs a lifestone/recall leg**, and it must
include a first-login stop, because login shares the same barrier and its
gate widened too.
After C5: ~~AP-22~~ (RETIRED 2026-08-06, bc4679cd) and ~~AD-10~~ (RETIRED
2026-08-06 by deletion, 886333a2) are both DONE. Historical text follows.
After C5: AP-22 (authored collision shapes), then AD-10 (remote
contact-plane projection), then the campaign's final matrix and ledger
closeout; vendor Slice 5 resumes.

View file

@ -1,163 +0,0 @@
# Recent-regression cleanup — plan (2026-08-03)
Three defects introduced by the 2026-08-02/03 stabilization batch, found while
reconciling the #281 test failures. All three are **ours, days old, and inside
the least-verified code in the tree**. They are cleared before C4 resumes so
six more placement routes are not stacked on top of them.
Issues: #282 (two cell fields), #283 (two world origins), #284 (silent park).
## Status — CLOSED 2026-08-03
All three landed and are user-accepted.
| Slice | Issue | Commit | Outcome |
|---|---|---|---|
| S1 | #284 | `97d11e6c` | Park reasons named; terminal on the contradictory state |
| S2 | #282 | `3c36b4cc` | One cell owner (`VisibilityCellId`); register row AP-133 |
| S3 | #283 | `898ff18b``89cf1e66` | Measured UNREACHABLE; permanent invariant instead of a restructure |
Connected Release gate (retail UI) on S1+S2: user verdict "works fine"; log
showed 9 completed reveals, 58 reveal events all `failures=0`, zero unhandled
exceptions, zero parked placements, graceful exit (`0c14c402`).
S3's probe run recorded zero disagreements across 11 reveals and six landblocks
spanning ~45 km, so ownership was deliberately left alone — the evidence
disproved the hypothesis, and the guard exists to keep it disproven.
Final complete Release solution: **10,844 passed / 4 skipped / 0 failed.**
Next: the original campaign order below, starting at C4 route 2.
## Why these first
Every one is an instance of the exact weakness the placement campaign exists to
remove: **two owners of one fact, with no single writer keeping them agreed.**
#282 duplicates "which cell is this in". #283 duplicates "where is zero". #284
is why both stayed invisible. Fixing them inside C4 would mean diagnosing them
through C4's much larger diff.
## Standing discipline for this plan
- Retail is the oracle. Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`
by `class::method` before writing.
- Root causes only. No timeouts, grace periods, suppression flags, or
catch-and-ignore. #284 in particular is observability + fail-fast, never a
retry cap.
- **The complete Release solution suite must be green before every commit.**
Focused-run-only gating is exactly what let #281#284 ship. The full suite
takes about 30 seconds; there is no excuse.
- Each fix is its own bisectable commit with root-cause evidence, and updates
the issue + divergence ledgers in that same commit.
---
## S1 — #284: make a parked placement visible (do this first)
Smallest, and it turns the other two from archaeology into observation.
1. Classify the park reason at the single site that produces it
(`RuntimeSetPositionState.PrepareMover`): awaiting collision generation,
awaiting Setup, awaiting world frame.
2. Fold per-reason parked counts into the existing physics ownership snapshot
(`RuntimePhysicsState.CaptureOwnership`) so they appear wherever ledgers are
already asserted, and in the connected gates' `report.json`.
3. Fail fast on unresolvable parks. A park awaiting the world frame *while a
local player is already registered* is not a wait — it is a contradiction.
Surface it as a committed invariant exception, the pattern `01f4791e`
established for receipt-ledger violations.
4. Convergence contract: parked entries must be zero at every stable
checkpoint. Wire that into the lifecycle/nine-stop gate assertions.
**Tests:** each park reason is reported exactly once and clears on resolution;
the contradictory park throws rather than retrying; ledgers converge to zero.
**Gate:** focused Runtime + complete solution suite.
---
## S2 — #282: one owner for an entity's visibility cell
1. **Establish the retail model.** `CPhysicsObj::set_cell_id` @0x0050f4f0,
`change_cell` @0x00513390, `set_cell_id_recursive` @0x00510da0,
`ShouldDrawParticles` @0x0050fe60. Retail carries ONE cell per physics
object, and particle gating reads that same cell. Write the pseudocode note
before touching C#.
2. **Audit the writers.** 12+ sites write `ParentCellId`
(`LiveEntityNetworkUpdateController` ×4, `RemotePhysicsUpdater` ×2,
`ProjectileController` ×3, `LiveEntityOrdinaryPhysicsUpdater`,
`LocalPlayerProjectionController`, `RemoteTeleportController`, …); 3 write
`EffectCellId`, all in `LiveEntityRuntime`. For each `ParentCellId` writer
record whether it also rebuckets — a rebucket with an exact cell currently
repairs the pair by accident. Produce the table before choosing the fix.
3. **Decide the shape.** `EffectCellId`'s documented purpose is narrow: outdoor
dat stabs that keep a null render parent while retail still gives them an
outdoor landcell. Live/interior entities were explicitly meant to use
`ParentCellId`. Preferred fix, in retail's direction: live entities stop
populating `EffectCellId`, the stab case keeps it as the documented
exception, and one owner writes the visibility cell that the effects path
reads. If the audit shows live entities genuinely need it, the alternative
is a single writer that maintains both — but never 12 independent writers
against a field that wins.
4. **Divergence register.** The two-field split is an adaptation from retail's
single cell. Add the row if none exists; delete it if step 3 collapses the
split.
**Tests:** an entity crossing a cell boundary keeps its particles and lights
attached; an equipped/attached child keeps its parent-relative behaviour; the
outdoor dat stab case is unchanged.
**Gate:** focused App + complete suite, then a **user visual check** — a
monster with an active spell effect crossing a cell boundary, and a lit static
object, indoors and outdoors.
---
## S3 — #283: one owner for the world origin
Sequenced last of the three and immediately before C4 route 3, which touches
the same portal code.
1. **Prove or disprove reachability first.** With S1 landed, assert at the
placement site that Runtime's frame center and App's `LiveWorldOriginState`
center agree; run the portal/recall routes. If they never diverge in
practice, the fix is a permanent invariant rather than a behaviour change —
record that and stop. Do not restructure on a hypothesis.
2. **Retail evidence.** How retail rebases its landblock offsets across a
teleport, and the ordering around `TAS_TUNNEL_CONTINUE` — the same
sequence #280 already needs read. Read once, use twice.
3. **Fix shape.** Runtime owns the world frame; App projects it. Today
`LiveWorldOriginState` is an independent owner with its own rebase edge.
Make it a projection of Runtime's frame, so there is exactly one origin and
the retirement-driven edge becomes a *publication* of that origin rather
than a second decision. This is the same ownership move the campaign has
already applied to entities, physics, and placement.
4. **Ordering invariant.** No placement may commit against an origin the
render side has not adopted. Whether that is expressed as a gate or made
structurally impossible falls out of step 3.
**Tests:** a teleport whose old-window retirement lags by many frames cannot
commit a placement against a mismatched origin; frame and origin rebase
together; ordinary movement rebases neither (already pinned by
`RuntimeWorldFrameTests`).
**Gate:** focused + complete suite, lifecycle/reconnect, and a **user visual
check** on repeated portal/recall arrivals with objects present.
---
## After S1S3
Resume the original campaign order, unchanged:
1. **C4 routes 27** — ForcePosition, portal (with S3 landed), remote
Create/Position, projectile correction, drops, pickup/parent/delete.
Fold in #276 and #277 where their route becomes authoritative.
2. **#280** — retail destination prefetch, landed adjacent to route 3.
3. **C5** — delete superseded writers, complete suite, lifecycle/reconnect,
nine-stop soak **on the final binary**, two-client observation, the #269/#278
slope-glide check. Only then retire AP-1, AD-1, AP-131, and AD-60's legacy
half.
4. **AP-22**`ShadowShapeBuilder` as sole authority for authored Setup
collision shapes.
5. **AD-10** — remote contact-plane projection through the real transition
sweep.
6. Final movement/collision matrix; ledger, architecture, roadmap, milestones,
memory, `CLAUDE.md`, `AGENTS.md`. Vendor Slice 5 resumes only after that.

View file

@ -1,266 +0,0 @@
# Campaign S — collision shape & response fidelity
**Opened:** 2026-08-06, immediately after #333/#337 closed (`ea83b043`).
**Status:** IN FLIGHT — overnight session 2026-08-07 ledger:
S1A (AP-157) closed by measurement, no code · S1B contract ready, not
implemented · S2 contract ready, not implemented · S3 CANCELLED (planned on a
misreading — see its section) · S1B LANDED (b3e43d22, #335 closed) and S2 LANDED (9671af02, AP-155
narrowed), Session-B dungeon gate USER-PASSED 2026-08-07 evening ("Feels
good!") · S4 half-landed (AD-65 shipped and USER-PASSED at the 2026-08-07 morning gate; AD-66 withheld
behind #341's measurement anomaly; AD-69 filed) · S5 closed (fix predated the
campaign; zombie register row) · S6 LANDED (containment: the camera provably reaches BOTH ACE-derived TOI
tails live — the rows' dormancy premise was false; guarded with counters +
one-shot unverified-mover log, four tests, sabotage on the real exemption
axis; AP-83/AP-91 rewritten CONTAINED-not-dormant, severity camera-feel
only) · **CAMPAIGN CLOSED 2026-08-07 night** with ONE honestly-open item:
AD-66's reland is blocked by the #341 codegen-shape measurement instability
(twice self-refused by its own stability gate; the ABA evidence and the
first discriminating experiment are in #341). The user's final slope look
travels with that reland. Next: #344, #343, #341's boundary hunt, then
vendors (M4) · #330 hoist landed, wiring
withheld with a seven-point scope map · #32/#338 pre-work both closed.
**Scope:** the twelve remaining collision-domain items — five shape/membership
divergences, three resolution-math divergences, two undecodable-math rows, and
three open bugs.
**SSOT while active:** this file. Digest:
`claude-memory/project_physics_collision_digest.md`.
---
## Why a campaign and not twelve tickets
Three of these rows edit the same two functions. `AP-157` and the `AP-156`
residual both live in `ShadowObjectRegistry.BuildFloodSpheres`; `AP-159`/`#335`
lives one call away in `CellTransit.BuildShadowCellSetFromParts` and in
`ShadowObjectRegistry.BuildBspPartSpheres`. Shipping them as separate tickets
means three review cycles over the same code and three chances to reintroduce
each other's bugs. This project already has a written rule about exactly that
shape of work: **shared-file slices are ONE agent against a pinned contract**
(`feedback_dont_parallelize_coupled_plan_slices`).
The second reason is ordering. **Membership gaps mask query gaps.** We just
watched it happen: AP-156 put geometry into the right cell and AP-158 threw it
away one layer down, so AP-156's entire visible benefit was invisible until
#333 landed. Anything upstream of the query has to be correct before a gate on
the downstream math means anything.
---
## The governing lesson from the last campaign
**The register rows are leads, not specifications. Measure before you fix.**
The evidence, all from the last two weeks:
- **AP-155(b) recorded the flood approximation as OVER-inclusive**, and used
that direction as the reason it was safe to defer. Measured, it was
UNDER-inclusive for 428 of 530 Setups — the opposite, and the dangerous
direction.
- **AP-156's risk column was wrong**, and its wrongness is precisely why #334
— a user-visible loss of collision — sat inside it unnoticed.
- **AP-22 described an unreachable branch.** 0 of 5,935 installed Setups could
satisfy its guard. The correct fix was deletion, not a port.
- **#331's headline claim was refuted outright.** The behaviour was already
retail-faithful.
So one row in four, in this exact domain, was materially wrong about its own
population, direction, or existence. **Every slice below opens with a
measurement that can cancel it.** A slice that measures its population at zero
closes as "row deleted", and that is a success, not a wasted slice.
---
## Slice order
### Pre-work — two cheap unblocks, before the campaign proper
Both are the user's own outstanding reports and both are blocked on a
measurement that costs far less than the fix. Neither is a campaign slice.
**PW-1 — #338, the step heights.** Setup `0x02000001` authors
`StepUpHeight = 0.600` / `StepDownHeight = 1.500`; the client resolves with
`0.400` / `0.400`. **First question is whether retail reads the authored field
at all** — grep `named-retail` for the step-height getters and their callers.
If retail substitutes its own constants, 0.4 is correct and #338 closes as a
non-defect. ~30 minutes. Do not touch code before that answer.
**PW-2 — #32, local-player cliff edge-slide.** This is the *other half of the
original two-bug report* and the thing the user will feel most, so it does not
sit behind six slices. Research is already done (`38db9fff`):
`CollisionInfo.SetContactPlane` latches last-known at all 13 call sites where
retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 — 22 bytes — never
does. Fix is ~2025 lines, mostly deletion, in 2 files. **Blocked on a live
`ACDREAM_DUMP_EDGE_SLIDE=1` capture**: the report's six-row decision table has
three rows that redirect the fix entirely. Needs the user at the client.
---
### S1 — The flood / membership pipeline
**Rows:** AP-157, AP-156 residual, AP-159 / #335.
**Files:** `ShadowObjectRegistry.cs` (`BuildFloodSpheres`, `BuildBspPartSpheres`),
`CellTransit.cs` (`BuildShadowCellSetFromParts` indoor arm),
`ShadowShapeBuilder.cs`.
**One agent. Pinned contract. Not parallelised.**
This is the walk-through direction and the largest single win in the list.
- **AP-159 / #335** — indoors we admit an EnvCell neighbour on a *sphere* test
where retail hands the part array to each cell's own `find_transit_cells` and
tests every part's sphere against that cell's portal planes in cell-local
space. Port the part-array overload. This is the last of AP-156's traversal
residual; the outdoor half already closed with #334.
- **AP-157** — retail's third `calc_cross_cells` branch floods from ONE
`CPartArray::GetSortingSphere`; we flood from every Sphere shape. Our
cylinder flood also ignores `CylHeight`.
- **AP-156 residual** — we scale the flood sphere by entity/part scale; retail's
`CEnvCell::find_transit_cells` reads only `CPhysicsPart::pos` and never
`gfxobj_scale`. Note the asymmetry before changing anything: retail's cross-cell
walk is itself under-inclusive for scaled parts and ours is not, so "match
retail" here means **deliberately adopting a retail bug**. That is a decision
to make explicitly with the user, not silently — over-inclusive is safe,
under-inclusive is the walk-through direction.
**Opens with:** an installed-DAT sweep giving each row its true population and
direction, measured against a DAT field that is not the one being fixed (the
non-circular-oracle rule that caught AP-156's identically-zero assertion).
**Gate:** offline differential over installed DATs, plus one live indoor run —
a dungeon with tight rooms and a door.
---
### S2 — Static publication shape fidelity
**Row:** AP-155. **Files:** `LandblockPhysicsPublisher.cs`,
`LandblockPhysicsContentBuilder.cs`.
The static-load paths emit an authored Setup Sphere as a height-capped
Cylinder. Different files from S1, so it is separable — but it must land
**after** S1, because S1's flood consumes what these paths produce, and
measuring S2's effect while S1 is in flight would confound both.
**Gate:** shares S1's live indoor run if S1 and S2 land together; otherwise
offline only, since a shape substitution's population is fully measurable from
the DATs.
---
### S3 — Animated collision pose — CANCELLED 2026-08-07 (the slice was planned on a misreading)
**Row:** AP-84 — which stays exactly as it is.
This plan's original S3 text claimed "a door's collision stays where the shut
door was." **That scenario cannot occur, and AP-84's own row says why:** an
open door is ETHEREAL (#150) and bypasses collision entirely, so the only
pose a door ever collides in IS the registered default pose. The row's risk
column already carries the honest residual — "an entity whose server-driven
motion state materially moves a BSP-bearing part while NON-ethereal would
collide at the stale default pose (no known case)" — with the revisit
trigger written. The register was right; this plan's summary of it was
wrong, which is the same reading failure the campaign's own governing
lesson warns about, committed by the campaign plan itself.
No fix, no gate, no door row in the morning sitting. AP-84 remains an
active, deliberate approximation.
---
### S4 — Push-out math
**Rows:** AD-65 + AD-66. **File:** `TransitionTypes.cs` (`AdjustOffset`) — both
in the same function, so one slice.
- **AD-65** — the `collisionAngle > 0` arm substitutes `result -= N * angle`
for retail's `Plane::snap_to_plane`. Recorded effect: downhill XY travel short
by cos²θ (25% at 30°, 50% at 45°).
- **AD-66** — the safety push-out substitutes `radius * ContactPlane.Normal.Z`
for retail's bare `radius`, in both the trigger comparison and the `zDist`
numerator.
**Correction carried in from the closeout:** AD-65 was previously described as
"a live lead for #269". That framing is **retracted**#269 closed 2026-07-31
on the user's own live gate, and AD-65's sign is *opposite* to that symptom.
AD-65 stands on its own merits. (#269's do-not-retry covers friction and jump
chains, which are byte-exonerated; `AdjustOffset` is a different function and
is not covered by it.)
This is feel, not pass-through. It cannot be gated by a test asserting "did I
fall through" — it needs a movement-feel gate.
---
### S5 — The Sledding flatness constant
**Row:** AD-55. **File:** `PhysicsBody.cs` (`calc_friction`).
We compare `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw decomp
literally computes `__fcos(0.17453292519943295)` = cos(10°) ≈ 0.984808. One of
the two is a decode artefact. **Resolve by byte-decoding the constant from the
PDB-paired binary** — there is a documented method for exactly this
(`reference_pe_byte_decode`), and it has already caught one inverted mapping
this project inherited from ACE.
Cheap. **Batch its live gate with S4's** — both are movement feel on slopes,
and asking for two separate slope-feel sessions wastes the only genuinely
scarce resource in this campaign.
---
### S6 — Containment, NOT a fix
**Rows:** AP-83, AP-91.
**These are not portable and should not be listed as fixes.** The PerfectClip
time-of-impact tails in `CCylSphere::collide_with_point` and
`CSphere::collide_with_point` are x87 sequences that do not decompile legibly;
we took them from ACE. There is no retail text to port. Pretending otherwise
would put a "port it" ticket in the backlog forever.
The honest deliverable is **containment**: prove no production mover sets
PerfectClip, and add a guard or test that fails loudly if one ever does. Then
the rows describe a branch we can show is unreachable, which is the same
resolution AP-22 got.
---
### Parallel track — #330, headless live-entity collision
The headless host registers no live-entity collision at all: a bot walks
through every NPC and every server-spawned object. The graphical client is
unaffected.
**Genuinely parallel.** Different host, and — unlike everything above — it
needs **no live gate from the user at all**, because the headless suite can
assert it directly. It is the one item that can proceed while the user is
unavailable, which makes it the right thing to pick up whenever a live gate is
blocking.
---
## Gate economics
The user's time at the client is the only scarce resource here. Live gates are
therefore **batched, not per-slice**:
| Session | Covers | What to do in-world |
|---|---|---|
| A | PW-2 capture | drive the cliff edges that misbehave; capture only, no fix yet |
| B | S1 + S2 | a tight dungeon with a door; walk boundaries, jump, drop a corpse |
| C | S3 | doors: open, walk through, close, walk into |
| D | S4 + S5 | slopes: run down, run across, sled, land on inclines |
Four sessions for the whole campaign. Everything else is offline.
---
## Definition of done
- Each row either **retired** with its evidence, or **rewritten** with a
corrected population/direction, or **deleted** as describing something that
does not exist. All three are acceptable outcomes.
- No row is closed on a test that re-computes the production expression as its
own oracle. Use an independent DAT field or an independent implementation.
- Every discriminating test is **sabotage-verified**: break the production line
and watch the test redden, in the same session it is written.
- `docs/ISSUES.md` and the divergence register updated in the **same commit**
as the code, per the register's own two binding rules.

View file

@ -1,88 +0,0 @@
# Morning gate — 2026-08-07 (one sitting)
**Status: COMPLETE 2026-08-07 — G1 user-passed ("Slopes feels good").**
Everything below folds the deferred visual gates into one sitting, ordered so
travel between sites doubles as #339 reproduction attempts. Launch is
prepared by the overnight session; **the client is NOT launched until you're
back** (your instruction).
## 0. Launch (the lead runs this when you say go)
Main checkout, absolute path, probes armed. The first `[step-h]` line in the
log must print the assembly path under
`C:\Users\erikn\source\repos\acdream\src\` — that is the wrong-binary guard,
now part of every capture.
## 1. Closed overnight with nothing owed to your eyes
- **#32 local edge-slide** — you passed it at the Rithwic cliff ("Yes works
now"). Ledger closed, AD-67 filed for the one kept residual.
- **#338 step heights** — headline refuted by full-capture statistics
(111,248 authored-pair resolves vs 358 placeholder ones); no production
change, nothing to look at. AD-68 records the benign async-residency
placeholder.
- **AP-157 (S1A)** — measured, both halves resolved without a code change:
the CylHeight half is retail's own behaviour (byte-verified), and the
sorting-sphere half is real vs retail's registration set but PROVEN unable
to change any collision outcome (flood and test geometry are the same
spheres). Fix deferred to the next bake-schema revision.
- **AD-55 (S5) — closed, and the fix turned out to be a WEEK OLD.** The
cos(10°) constant has been live in production since 2026-07-30
(`252e8068`); you've been playing on it. Tonight's byte-decode is an
independent confirmation. The register row claiming otherwise was a
ZOMBIE: an unrelated revert (`a8a7d64b`) resurrected the retired row —
and, found by auditing that same revert, had also silently DELETED the
then-active AD-56 row. Both corruptions fixed; the class is now a memory
rule. No feel gate owed for S5.
## 2. The sitting — ONE row
| # | What | Where / how | Pass looks like |
|---|---|---|---|
| G1 | **S4 / AD-65 — downhill slope feel.** The away-from-plane response now
snaps to the surface (XY preserved) instead of projecting (XY shrunk by
cos²θ — 25% at 30°, 50% at 45°). | Run DOWN a long slope (the Rithwic
descent works), then across it diagonally; jump down-slope and land
running. | Downhill ground speed feels like retail — no "wading" slowdown
on descents; no new stutter or floatiness; landings keep momentum
downhill. |
That is the whole sitting: **one slope run, ~3 minutes.** Everything else
either closed with no gate owed or was deliberately withheld (below).
## 2.5 Withheld / deferred overnight — nothing to test, decisions recorded
- **AD-66 (the push-out's bare radius) — WITHHELD, issue #341.** The port is
byte-proven twice over, but the landing produced a measurement that
contradicted itself (same binaries, opposite outcomes flipping with test
assert shape). Parked behind an instrumentation plan rather than guessed
at. AD-69 filed alongside: the same block misses retail's seam-frame
correction.
- **#330 headless collision — wiring withheld, hoist landed.** Dual review
converged on a real dependency the contract missed: headless has no
remote-motion tick, so spawn-registered shadows would freeze into phantom
obstacles. The issue now carries the full seven-point scope map.
- **S1B (indoor box-admit) and S2 (static sphere emission)** — contracts
written and committed, not implemented; next session picks them up
directly.
- **S3 — cancelled**: planned on a misreading; open doors are ethereal, so
AP-84's approximation is behaviourally equivalent. The register was right.
## 3. Free riders during travel
- **#339 portal-space hang** — every portal you take is a reproduction
attempt; if you get stuck again, the log captures the readiness flags and
the session becomes the diagnosis session. Nothing to do actively.
## 4. Deliberately NOT in this sitting
- **C5c's connected-gate batch** (~1 hr: D-1 scenarios, AP-136 six-step park,
route-7, two-client, nine-stop soak) — standing debt by your direction;
say the word any morning and it becomes its own sitting. The probe family
stays in the tree until it runs.
- **AP-156 scale question** — retail does NOT scale cross-cell flood spheres;
we do. Matching retail here means adopting a retail bug whose direction is
walk-through for shrunk objects. Your call, explicitly, before any code
changes: keep our over-inclusive scaling (safe, divergent) or match retail
(faithful, under-inclusive for scale<1). One sentence from you settles it.

View file

@ -1,379 +0,0 @@
# Campaign A — Audio retail-feel parity
**Status: CODE-COMPLETE 2026-08-08 — awaiting the user listening gate.** All six slices landed (see the ledger). Research phase complete;
six-lane named-retail decode done, all load-bearing claims byte-verified
against the PDB-paired 2013 binary (BN pseudo-C alone was NOT sufficient —
see "BN traps" below).
**Goal:** the client sounds like retail. Every divergence between our audio
runtime and the 2013 EoR client is either fixed to the retail mechanism or
recorded in the divergence register with a reason.
**Research base (read the lane note before implementing its slice):**
| Lane | Note | Owns |
|---|---|---|
| 1 | `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | SoundManager, falloff, pan, voice pool, prefs |
| 2 | `docs/research/2026-08-08-audio-retail-ambient-runtime.md` | AmbientSound/ConstantSound/IntermitSound runtime |
| 3 | `docs/research/2026-08-08-audio-retail-ambient-authoring.md` | Region-file authoring chain, dat coverage |
| 4 | `docs/research/2026-08-08-audio-retail-dat-layer.md` | SoundTable/Wave formats, selection model, dat census |
| 5 | `docs/research/2026-08-08-audio-retail-server-sounds.md` | 0xF750 wire path, play_sound, trigger catalog |
| 6 | `docs/research/2026-08-08-audio-retail-music-absence.md` | Music (there is none), MediaMachine, AdminEnvirons |
The older `docs/research/deepdives/r05-audio-sound.md` is SUPERSEDED where it
conflicts with the lane notes (its §5.1 falloff, §6 music, and §7 ambient
sections are wrong — Ghidra-era `FUN_xxx` reads that the named decomp + byte
decode overturned). Slice A6 adds the banner.
---
## What retail's audio engine actually is (one page)
Retail is a **2D pan+gain engine**, not 3D audio. Every gameplay buffer is
created with `m_3D = 0`; the DirectSound 3D listener the client sets up is
dead code. Spatialization is CPU-side per voice at play time:
- **Gain** (`SoundManager::GetAttenuation @ 0x00550020`; byte-decoded):
`g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, then multiplied by ONE
master knob (`effect_sound_volume` or `ambient_sound_volume`) — clamp
first, multiply second — then `db = ceil(20·log10 g)` with a hard floor at
50 dB, below which the voice is **not allocated at all**. Audible radius
≈ 94.2 m at vol 1.0.
- **Pan**: `pan_dB = (int)(15·sin(Δbearing))`, truncating toward zero,
saturated at ±15 dB, forced to 0 when `(int)distance < 5`. No front/back,
no elevation.
- **Listener** = `SmartBox::viewer` — the **collided third-person camera**
Position, refreshed once per rendered frame (`SmartBox::set_viewer` @
`0x00452D36`, `SmartBox::update_viewer` @ `0x00453CE0`), falling back to the
player's own position when the camera sweep fails. Only its origin and
`Frame::get_heading` are read. (An earlier draft of this plan said the
listener is the player and called acdream's camera listener a defect —
wrong, and corrected at A2. Do not re-"fix" it.)
- **Voice pool**: allocator is `SoundManager::PlaySoundInternal @ 0x0054FEC0`.
Eviction compares the DAT-authored **float priority** (0..1); equal
priority never evicts. (`FUN_00550AD0` cited in our code is a hash-table
constructor — wrong symbol.)
- **No loops, no pitch**: retail never sets the DSound loop flag and never
calls `SetFrequency`. "Looping" ambients are re-fired one-shots.
- **Variant selection** (`SoundTableData::Lookup` + play sites): pick
`idx = (int)(roll01 · (n1))` — uniform over all but the LAST entry
(a genuine Turbine off-by-one; the last variant is unreachable and a
faithful port keeps that) — then a SEPARATE Bernoulli gate
`rand()/32767 < probability`, else **silence**. Probability is a gate,
not a weight.
- **Volume field** is unbounded gain (dats go up to 10.0); retail clamps
only AFTER the distance divide, so >1 volumes extend audible range.
- **Prefs** (`InitPrefs @ 0x005503F0`, 8 keys): three float volumes
(effect / ambient / interface — interface is registered but **never
read**), three enable bools, `Sound Features` (==1 disables pan),
`Play Sound Only When Active`. There is NO music knob.
- **Quirk (faithful-port decision)**: effect and ambient volumes are each
applied twice (once at the play site, again inside GetAttenuation) — the
sliders are effectively **squared**.
**Sound triggers, exhaustively** (lane 5): (1) animation hooks
(SoundHook/SoundTableHook/SoundTweakedHook) — footsteps, combat swooshes,
all authored in MotionTables; (2) the server `Sound` event **0xF750**
(`guid, SoundType, vol`) — hits, wounds, wield, pickup, locks, lifestone,
spell resists; retail queues an event for a not-yet-known guid and replays
it on CreateObject, and plays at the WIRE volume, ignoring the table
entry's volume (the hook path does the opposite); (3) PhysicsScripts
(0xF754/5 — already live in acdream); (4) UI sounds via the ClientUISystem
sound table loaded by `DBObj::GetByEnum(0x22, slot 7)`; (5) region-authored
ambients (below). `CPhysicsObj::play_sound` has exactly ONE caller — the
0xF750 handler. There are NO client-local collision/jump/water sound call
sites; inventing one is a divergence.
**Ambients** (lanes 2+3): authored entirely in `region.dat`
(`Region.SoundInfo``AmbientSTBDesc[]` referenced by scene types ← terrain
types). On every **objcell change** (24 m), `CellManager::ChangePosition`
rebuilds weights by walking the **3×3 landblock ring × 64 land cells each**,
decoding each cell's terrain word to (terrainType, sceneIdx) and
accumulating per-sound inverse-square weight (1.0 inside 20 m, `(20/d)²` to
120 m, 0 beyond) plus an 8-way bearing histogram. Playback is a min-heap of
absolute deadlines ticked from the frame loop; each pop plays a one-shot
and re-arms. `base_chance == 0`**ConstantSound**: non-positional,
volume = its weight share of total (a real terrain crossfade), re-fires
every `min_rate` s. Non-zero ⇒ **IntermitSound**: authored fixed volume,
positioned at a random accumulated bearing ±11.25° at distance
`min + (maxmin)·t²`, gated by `roll ≤ base_chance`, interval
`RollDice(min_rate, max_rate)`. **Indoors is silent by design**
`CEnvCell::add_ambient_sounds` is an empty folded `ret`; EnvCell has no
sound data. No day/night/weather selection exists.
**Music does not exist** (lane 6): the linked winmm MIDI player has zero
callers (`midiPlay` = 4 textual occurrences: definition + its own queue
drainer; verified independently), "music" appears 0 times in the 65 MB
decomp, no SoundType music member, no music pref, no music files shipped.
`MediaMachine` is a UI-state media bytecode VM whose `Update_Sound` routes
LayoutDesc-authored waves/table rows to the interface bus.
### BN traps (binding on every slice — reread before porting)
Binary Ninja renders x87 memory-operand compares as unimplemented `bool p`
and elides the constants; **five** sites would have ported with inverted
polarity or zeroed math: `is_continuous`, both `CanHear`s, `PlayNow`,
`PlayProbability`, plus `GetAttenuation` printing `* 0f`. Byte-decode the
PDB-paired binary (`reference_pe_byte_decode.md` workflow) for ANY float
compare or constant in this subsystem. The lane notes contain the verified
values; if a needed constant is not in a note, decode it — do not trust
the pseudo-C rendering and do not guess.
---
## Where acdream is today
Working and retail-correct-in-shape: the animation-hook trigger path
(`AudioHookSink`, correctly the only client-local trigger), SoundTable/Wave
dat parsing (byte-exact vs retail), `SoundId` enum (golden-conformance
tested), entity→SoundTable resolution (Setup-then-wire precedence),
world-audio quiescence across portal transitions, AL buffer budget/lifetime.
Divergent or missing, ranked by audible impact:
| # | Defect | Where | Symptom |
|---|---|---|---|
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no 50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
| 7 | UI sound bank absent; AdminEnvirons stingers logged not played; portal enter/exit cues missing | — | TS-54, AP-115 |
| 8 | `PlayMusic`/`StopMusic`/`MusicVolume` model retail code that never runs | `IAudioEngine`, settings | Dead API + misleading settings knob |
| 9 | Dead code: `AudioFalloff` (wrong constants, unused), wrong `FUN_00550ad0` citation, invented `PitchMin/PitchMax`+`Loop`+`Is3D` fields on `SoundEntry` | `AudioModel.cs`, engine header | Traps for future readers |
Register rows in scope: **AP-28** (retire at A2), **AP-115** sound half
(retire at A4), **TS-29** (retire at A5/A6), **TS-54** (retire at A4),
**TS-9** (re-scope at A6 — dat census says exactly 1 of 786 waves is MP3).
Issue **#321** (sound-cache decode-dedup race) folds into A6.
---
## Slices
Ordering is audible-value per effort; A1A2 are the "it sounds wrong"
fixes, A3A4 the "it's silent where retail speaks" fixes, A5 the big new
system, A6 the cleanup. Each slice: grep-named → (byte-decode if any new
constant) → pseudocode check against lane note → port → conformance tests →
build/test green → commit; user listening gates where marked.
### A1 — Selection-model correctness (small; biggest audible fix)
Replace `SoundCookbook.Roll` with retail's exact model: uniform
`idx = (int)(roll01 · (n1))` (preserving the last-entry-unreachable
off-by-one), then the separate Bernoulli probability gate returning
"silence" — including for single-entry lists. Priority stays float [0,1]
end-to-end (`SoundEntry.Priority`, engine slots). Volume passes through
unclamped; clamp moves to post-attenuation (staged here, consumed by A2).
Delete the invented `PitchMin/PitchMax/Loop/Is3D` fields. Rewrite
`SoundCookbookTests` against golden values from the lane-4 note's decoded
tables; add a distribution test for the gate.
Acceptance: conformance tests green; connected sanity — creature idle
chatter audibly rare (Speak1 ≈ 5% per trigger, was 100%).
### A2 — Falloff/pan/listener/voice parity (retires AP-28)
Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
clamp-after, ceil-dB, 50 dB no-allocate floor, `15·sin(Δheading)` pan
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
pan (AL_POSITION azimuth from pan only); remove AL's distance model and the
listener orientation math.
**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft
of this plan said the listener must move "from camera pose to player
position/heading" and listed "listener = CAMERA" as a defect. That was wrong,
and it was written before lane 1 landed. Retail's listener IS the camera:
`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person
camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera
setup, refreshed once per rendered frame from `SmartBox::update_viewer` @
`0x00453CE0` (falling back to the player's own position when the sphere sweep
fails). acdream's chase camera collides too, so the position source was already
faithful; only the HEADING extraction changes, since retail reads
`Frame::get_heading` — one compass bearing — and never a forward/up basis.
Do not "fix" this back.
Eviction compares float priority (equal never
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
Keep the squared-volume quirk faithful (register row if we later soften
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
Interface mirroring retail's knobs; note interface is read by no retail
path (we wire it to the UI bus anyway — divergence row, deliberate).
Acceptance: unit tests on gain/pan tables (golden distances from lane 1
note); **user listening gate** — side-by-side with retail: walk away from
a blacksmith's hammering, confirm matching fade-out distance (~94 m) and
pan behavior.
### A3 — Server sound path (0xF750)
Parse `Sound` (guid, SoundType u32, volume f32) in the message router;
route to a new `ServerSoundController`: resolve guid → entity; unknown
guid ⇒ queue the event and replay on CreateObject (retail
`CObjectMaint` behavior); known guid without SoundTable ⇒ silent drop;
play via the SoundTable at the **wire volume** (ignore table volume —
asymmetric with the hook path, byte-verified). Position at the entity's
current origin.
Acceptance: wire-format conformance test (three-oracle layout);
connected gate — melee hits, item pickup/drop, lifestone bind audibly
fire against ACE.
### A4 — UI + interface sounds (retires TS-54, AP-115's sound half)
Load the ClientUISystem sound table (`GetByEnum` cache 0x22, enum slot 7 —
resolve the actual DID at port time from `ClientUISystem::GetUISoundTable`).
Route `PlayUi(SoundId)` through it (delete the no-op). Wire:
AdminEnvirons 0x65..0x7C → `PlaySoundFromCenter` stingers
(`WorldEnvironmentController.ApplyAdminEnvirons` already parses them);
portal enter/exit `UI_EnterPortal`/`UI_ExitPortal`; button/panel cues where
the retained UI already has command seams; `MediaDescSound` support in
`LayoutImporter` (DatReaderWriter parses it; interface bus, per lane 6).
Acceptance: connected gate — `@environs` thunder/drums audible; portal
enter/exit cues audible on recall; **user listening gate** vs retail.
### A5 — Region ambient system (retires TS-29's ambient half)
New `AmbientSoundSystem` (App layer, owned like other world controllers):
rebuild on objcell change (reuse streaming's cell-transit signal), walk
the 3×3 ring × 64 cells via the SAME terrain-word decode the scenery
pipeline uses (`SceneryGenerator`-shared helper), accumulate weight
(1.0 ≤ 20 m, `(20/d)²` ≤ 120 m) + 8-way bearing, build
Constant/Intermit instances from `AmbientSTBDesc` (`base_chance == 0`
constant — the byte-verified polarity), min-heap of absolute deadlines
ticked per frame, one-shots through the ambient volume path (squared,
faithful). ConstantSound non-positional; IntermitSound positioned at
random accumulated bearing, `min + (maxmin)·t²` distance. Teardown on
world transition via the existing quiescence edge. Indoors: NO ambients
(retail-faithful); `seen_outside` cells get the outdoor set. Delete
`StartAmbient`/`StopAmbient` from `IAudioEngine` (wrong shape — looping
handle API models a mechanism retail doesn't have).
Acceptance: unit tests on weight accumulation + scheduler with a synthetic
region; **user listening gate** — Holtburg outdoors vs retail side-by-side
(birdsong/wind character and rough cadence), dungeon silence, ambient
crossfade walking shore → grass.
### A6 — Deletions, bookkeeping, and the long tail
- Delete `PlayMusic`/`StopMusic`/`MusicVolume` and the `AudioSettings.Music`
knob (settings migration: drop the field, tolerate old json). Retail has
no music system; register row NOT needed once the API is gone (nothing
diverges — absence matches retail).
- Delete dead `AudioFalloff` (superseded by A2's ported math).
- r05 doc: SUPERSEDED banner pointing at the six lane notes; corrections
list from lane notes §12/§13.
- TS-9 re-scope: 1 MP3 wave in the shipped dats (`0x0A000393`, ~2 s) —
either a ~50-line managed MP3 decode for one asset or an accepted-loss
row with the census cited. ADPCM count to be measured the same way
before deciding.
- #321: make `DatSoundCache` decode-dedup safe under concurrent access
(single-flight per wave id) — the full-suite flake.
- Register sweep: retire AP-28/TS-29/TS-54 rows in their landing slices'
commits (rule 1); add rows for: interface-volume wired (A2), any
softened quirk, and anything discovered mid-campaign.
Acceptance: build/test green, register diff reviewed, no orphaned
API/settings references.
---
## Out of scope (explicitly)
- Client-local physics sounds (collision/jump/water) — retail has none;
the server sends them. Do not invent.
- Indoor ambient beds — retail is silent indoors.
- A music system — retail has none. (If we ever WANT music, that's a
new-feature decision for the user, not parity work.)
- HRTF/doppler/reverb — no retail counterpart.
## Rollback
Each slice is one commit (A6 possibly two); rollback is `git revert
<slice-sha>`, recorded in this doc's ledger as slices land. A2 and A5 are
the only slices touching frame-loop code paths; both are behind the
existing audio-availability guard, so `ACDREAM_NO_AUDIO=1` remains the
global kill switch.
## Ledger
| Slice | Status | Commit | Gates |
|---|---|---|---|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `e42b9948` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
| A3 | **COMPLETE** 2026-08-08 | `8bc458fb` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
| A4 | **COMPLETE** 2026-08-08 | `6eaa490b` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. |
| A5 | **COMPLETE** 2026-08-08 | `7c4dd1ad` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** |
| A6 | **COMPLETE** 2026-08-08 | `dd2cb92b` | Full Release suite 11,739 passed / 4 skipped. Deleted the music API (`PlayMusic`/`StopMusic`/`MusicVolume` + the `AudioSettings.Music` knob); exposed the Ambient slider now that A5 drives it; reset the invented 0.8 ambient default to retail's 1.0; SUPERSEDED banner on `r05-audio-sound.md` listing its five wrong sections; TS-9 re-scoped to the measured one-wave blast radius. **Deferred:** #321's decode-dedup race (a pre-existing concurrency flake, not audio-parity behaviour — not fixed speculatively without reproducing it). |
---
## CAMPAIGN CLOSEOUT (code-complete 2026-08-08)
Six slices, six commits, `c69b3bde` → A6. The full Release suite ends at
**11,739 passed / 4 skipped / 0 failed**, up from 11,563 at campaign start;
the audio subsystem went from 42 tests (one of which pinned the wrong model)
to ~215 conformance tests written against byte-decoded values.
**What was wrong, and is now right:**
| Was | Now |
|---|---|
| Probability treated as a selection weight, and skipped entirely for the 4,183/4,184 single-entry sounds | Retail's uniform `(n1)` pick plus an independent Bernoulli silence gate |
| OpenAL 3-D spatialization, `2/d` falloff, no cutoff | Retail's CPU 2-D model: `25·vol/d²` past a 5 m knee, 50 dB no-allocate floor (~94 m), ±15 dB sine pan with a 5 m dead zone |
| Voices evicted by gain | Evicted by DAT-authored float priority, strictly-less, ring order |
| `0xF750` unparsed — every server cue silent | Parsed, guid-queued-and-replayed, played at the wire volume |
| No interface sound bank | Bank DID resolved from the dats' EnumIDMap chain; portal cues + 21 AdminEnvirons stingers live |
| No ambient system at all | Region-authored, per-land-cell, 3×3 ring, deadline-queue one-shots with terrain crossfade |
| A music API | Deleted — retail has no music system |
**Process notes worth carrying forward:**
1. **Binary Ninja could not be trusted anywhere in this subsystem.** Five
float compares render inverted or with elided constants; `GetAttenuation`
prints `* 0f`, which ports as silence at every distance. Every load-bearing
value here came from byte-decoding the PDB-paired binary. The lane notes
record the verified values; a future reader should decode rather than
re-read the pseudo-C.
2. **Two research notes were wrong and were corrected in place** — lane 1's
30 m decibel row (contradicted its own gain column) and lane 5's
transposed `GetByEnum` arguments (which would have made the UI bank
unresolvable). Both were caught by recomputing rather than copying.
3. **The reviews earned their cost.** A2's review caught a pan mapping that
saturated to full separation where retail gives 15 dB. A5's caught a
coordinate-frame error that would have made the entire ambient system
silent with nothing logged — the tests passed, the build was green, and it
would have failed only at the listening gate.
4. **An architecture guard caught a design error the tests could not**:
`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks` rejected an
`Action<float>` frame hook and forced the typed `IAmbientFramePhase`.
**A4 correction (2026-08-08, from a user question):** the enter cue was hung on the
`EnterTunnel` event — the first tunnel-family frame — instead of the sequencer's
`PlayEnterSound`, which is `Begin()` and is what retail's
`BeginTeleportAnimation` @ `0x004D638E` plays. That delayed it by a whole
TunnelFadeIn. Both cues now fire on the sequencer's own dedicated sound events
(`PlayEnterSound` had been emitted and dropped by every consumer since R6), and
`PortalCues_FireOnTheSequencersOwnSoundEvents_NotOnTheTunnelVisuals` pins the
moments. The exit cue was already correct.
**Listening-gate round 2 (2026-08-08, inn-chatter finding):** interior
soundscapes (inn talk-and-laughter) are NOT dat-authored — proven by three
installed-dat scans pinned in `EnvCellSoundEmitterInventoryTests`: no interior
static carries an ambient-slot table, no Setup in the whole portal dat
references one, yet 23 ambient-only soundscape banks exist. They are
WIRE-BOUND: the server attaches them to emitter objects and fires the slots
over 0xF750 (ACE: `EmoteType.Sound` heartbeat emotes). Our A3 path is the
receiver and is live; a probed session (`ACDREAM_PROBE_SOUND_WIRE=1`)
received ZERO 0xF750 events across a town walkabout, so the silence is ACE
world-content, not a client drop. Also added this round: `Ctrl+M` instant
mute (`AcdreamToggleAudioMute` → AL listener gain, unused since A2 — silences
playing voices immediately without touching the retail mixing math or any
persisted setting).
**Still owed:** the user listening gate (A2 falloff, A4 cues, A5 ambients) and
the connected gates for A3/A4. Open rows: AP-173, AP-174, TS-64, TS-65, TS-66,
TS-67, TS-9 (re-scoped), #321.

File diff suppressed because it is too large Load diff

View file

@ -1,445 +0,0 @@
# Campaign OP — retail four-tab Options panel
> **For agentic workers:** slices are executed by ONE Sonnet implementer at a
> time against this contract, then dual-lens Opus-reviewed, per the binding
> process rules in §8. The four research docs in §1 are the spec's data
> appendix — implementers MUST read the cited sections before coding; every
> table this plan references by section number is committed there in full.
**Status: CODE-COMPLETE 2026-08-11, PARKED 2026-08-11 (user direction) —
all nine slices landed and reviewed; OP1/OP2/OP7/OP9 CLOSED; OP3OP6 + OP8
owe their connected user gates.** Four connected gate rounds ran before
parking; every finding was root-caused and fixed same-day: #372
(blank tabs + click diagnostics), #374 (dropdown pointer routing; #376
split out), #375 (keyboard string resolver + parked prototypes + tab
activation), #371 (viewport clip), the AD-78 store-only dimming
(user-directed), and the gate-4 batch #378#382 (dropdown chrome, chat-only
opacity scope, slider captions from DAT catalog 0x78000000, the AP-205
footer field, the AP-206 state-cascade fix). **Resume point: the gate-4
fixes (`c1218426`..`d1c60df9`) are committed and probe-verified but have
NOT been seen by human eyes — the user's re-check of those five plus the
full §OP3§OP6/§OP8 script is the remaining work. Launch with
`ACDREAM_RETAIL_UI=1`.** Gate script:
`docs/research/2026-08-11-campaign-op-test-script.md`. Open tail: #373,
#376, #377 (fullscreen startup crash — settings.json workaround noted in
the issue), AP-198/199/202/203/205/206, the Shift-chord display cosmetics
and the radar-text-over-panel z-order noted in session.**
**Goal:** retail's four-tab in-game Options panel (Gameplay Options /
Character / Chat / Config), retail-faithful mechanics end-to-end: authored
LayoutDescs, the real option storage/wire split (`0x0005` single-option vs
the batched `0x01A1` PlayerModule blob), retail Apply/Reset/Defaults
semantics, live consumers where acdream has the subsystem and honest
store-only + register rows where it doesn't, plus the headless-bot
`characterOptions` seam. Campaign handoff:
`docs/research/2026-08-10-settings-track-handoff.md`.
**Architecture:** the panel is retained retail UI exactly like Campaign CH's
chat windows — LayoutDesc `0x2100002B` imported through `LayoutImporter`,
mounted in the `gmFloatyPanelUI` host `0x2100006E` under
`RetailWindowManager`, driven by a focused controller. All option
storage/policy is Runtime-owned (`RuntimeCharacterOptionsState` widened to
the full retail table); the graphical panel and the headless host are both
consumers of the one generation-gated `IRuntimeCharacterCommands` seam
(CH3's precedent). Wire builders live in `AcDream.Core.Net` beside the
existing `0x0005` codec.
**Tech stack:** existing retained UI stack (`DatWidgetFactory`,
`LayoutImporter`, `UiRoot`, `RetailWindowManager`), `AcDream.Runtime`
gameplay owners, `AcDream.Core.Net` message builders, `DatCollection` for
DAT reads. No new dependencies.
---
## 1. Research base (committed; the spec's data appendix)
| Doc | What it pins |
|---|---|
| `docs/research/2026-08-10-options-panel-structure.md` (lane A) | Layout `0x2100002B` structural inventory (§10.1), tab table property `0x2E`, row-template mechanism (ListBox `P0x64` + `AddItemFromTemplateList`), Apply/Reset/Defaults + visibility semantics (§6, §10.4), Chat tab's 13 checkbox masks + defaults (§8), Config tab's 27 rows + `UserPreferences.ini` keys (§9), open path (F11 action `0x1000001A`, toolbar button `0x1000019B`) |
| `docs/research/2026-08-10-character-options-map.md` (lane B) | The 50-row / 6-group Character-tab inventory with per-row storage bit, wire route, ACE handling, acdream consumer state (§2§5); implement-vs-store split (§7.1); bot tiers (§5.2) |
| `docs/research/2026-08-10-set-character-options-wire.md` (lane C) | The `0x01A1` body = `PlayerModule::Pack` field order (§2.3§2.7), header invariant `0x460`, the 21-id auto-save table (§3.2), 480 s timer + logout + Apply flush triggers (§3.3§3.5), ACE acceptance/landmines (§5), CH3 builder post-mortem (§6) |
| `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` (lane D) | The seven Gameplay-tab button behaviours with byte-verified strings (§1§4), `gmKeyboardUI` structure + DAT ActionMap storage (§5§6), per-button implementability (§7.1), Config-tab ordered dump (§7.3) |
Coordinator-verified during planning (this session): toolbar button
`0x1000019B` authors `P0x12 = 0x1000001A` (committed fixture);
`retail-default.keymap.txt:148` binds `ToggleOptionsPanel` to `DIK_F11`;
`PlayerModulePackHeader` verbatim at `acclient.h:7835`; the headless
local-write gap at `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs`
(`SetSingleOption`) vs `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:347`;
**U1 closed**: `UIOption::InqDefaultGameplayOptionProperty @0x004ef8d0`
resolves per-option defaults from the DAT `DBPropertyCollection` at
`DBCache::GetDIDFromEnumStatic(0x16, 2)` — the Defaults button restores
DAT-authored values.
## 2. Design decisions (stated, per the campaign directive; reactable at gates)
- **D1 — the retail Options panel is acdream's one in-client settings
surface.** Lane D established the F11 `SettingsPanel` was never rendered
post-V11 (`ToggleSettingsPanel()` no-op; only `IPanelRenderer` is a test
fake). Retail's own F11 IS `ToggleOptionsPanel`. So: F11 + the toolbar
button open THIS panel; acdream's client-only settings live on the
**Config tab** (retail's own client-settings tab — its 27 rows are
`UserPreferences.ini` preferences, nothing on the wire), backed by
acdream's existing settings store. The old `SettingsPanel`/`SettingsVM`
IPanel surface is retired in OP9; its tested keybind model feeds OP8.
- **D2 — retail's wire split ships exactly.** The 21 auto-save ids send
`0x0005` immediately; the rest dirty the module and ride the real
`0x01A1` blob with retail's three flush triggers (Apply, logout, 480 s
timer). No "send everything as 0x0005" shortcut (lane C: the split is
load-bearing in both directions).
- **D3 — the 50th Character row ships.** "Listen to PK death messages" is
2015-client; the DAT string exists (`0x0D16E9A3`), ACE maps id `0x34`
`CharacterOptions2 0x02000000` but never reads it. Ship the row
(user-gate axiom: the user's retail memory includes it), wire+store only,
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) 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
emits the button's own byte-verified failure notice through the
interface-text seam (retail text, not invented), register rows filed.
In-Game Help mirrors retail-with-missing-`ACHelpPlugin.dll` behaviour.
- **D6 — Exit to Character Selection behaves as Exit Game** (+ register
row) until a pre-world character-select flow exists, but retail's
confirmation dialog (`ID_Client_EndCharacterSessionConfirm`) and mid-air
refusal ("Cannot log off while in mid-air.", byte-verified) ship now.
- **D7 — Group C re-points to server truth.** The seven currently-live
`settings.json`-backed options (lane B §7.1 group C) become
server-bit-authoritative per CH3 precedent (reseed from
PlayerDescription; local-write-then-send). `GameplaySettings`' 16
server-shadowing booleans die in OP9.
- **D8 — bots declare options by NAME.** The K1 strict config gains an
optional `characterOptions` block accepting exactly the lane-B tier-1+2
names; unknown names fail load (ACE throws on unknown ids — never let one
reach the wire). Diff-then-send after PlayerDescription seeds state;
idempotent on reconnect.
## 3. Slice map
Dependencies: OP1 → (OP4, OP7); OP2 → (OP3, OP4, OP5, OP6, OP8); OP3 → OP4/5/6
(the shell hosts the tabs). OP7 needs only OP1. Execution order below is the
default; OP7 may run any time after OP1 when the tree is free.
| Slice | Contract (summary) | Gate |
|---|---|---|
| OP1 | Runtime option map + dirty model + the real `0x01A1` builder + headless local-write fix | automated only |
| OP2 | Type 8 tab control + Type 5 template-list ListBox + remaining `UIOption_*` widget mappings + fixtures | automated only |
| OP3 | Panel shell + open paths + Gameplay tab end-to-end | user (connected) |
| OP4 | Character tab: 50 rows, consumers, Apply/Reset/Defaults | user (connected) |
| OP5 | Chat tab: opacity sliders + 5 per-window filter blocks | user (connected) |
| OP6 | Config tab: 27 rows over the client settings store | user (connected) |
| OP7 | Headless `characterOptions` block | automated + bot-vs-ACE run |
| OP8 | Configure Keyboard screen | user (connected) |
| OP9 | Closeout: retire dead surfaces, bookkeeping, test script | user (final matrix) |
---
## 4. Slice contracts
### OP1 — the Runtime option map, dirty model, and the real blob
**Files.**
- Modify `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`
(`RuntimeCharacterOptionsState`, `:628`): widen to the full table.
- Create `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs`: the ONE
typed table `PlayerOption id → (word: Options1|Options2, mask, isAutoSave,
clientDefault)` for ids `0x00..0x34`, transcribed from lane B §2 (verbatim
`acclient.h:4162` enum names) + lane C §3.2 (auto-save column) + D3's
`0x34` row. Reject `0x35`/`0x36` and unknown ids at the seam (lane C
§5.4.3 — ACE throws).
- Modify `src/AcDream.Runtime/GameRuntimeCommands.cs`: add
`IRuntimeCharacterCommands.SaveOptions(RuntimeGenerationToken)` (the
blob-flush verb) beside `SetSingleOption`.
- Modify `src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs`
and `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs` +
`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:335-350`: move
local-write-then-send INTO the shared Runtime seam so BOTH hosts get
retail's ordering (fixes the headless gap lanes B+C independently found).
The `LiveSessionRuntimeFactory` local closure is deleted, not duplicated.
- Create `src/AcDream.Core.Net/Messages/SocialActions.cs` addition:
`BuildSetCharacterOptions(...)` per lane C §2.7 EXACTLY — header always
`0x460` OR-ed with present optional sections; four unconditional u32s
(header, options1, spellbookFilters, options2 in pack order §2.3); echo
last-parsed shortcuts / 8 spell lists / desired comps rather than zeroing
(§5.3); NEVER set `0x100` (lane C U2); omit `0x200` while acdream packs
nothing (safe per §2.5); 4-byte tail pad.
- Modify `src/AcDream.Core.Net/WorldSession.cs`: `SendSetCharacterOptions`.
- Dirty model in `RuntimeCharacterOptionsState`: `MarkDirty` on any
non-auto-save change, `FirstDirtiedAt`, flush triggers = explicit
`SaveOptions` command, session logout, and the 480 s timer (retail
constant, lane C §3.3) driven from the existing Runtime tick.
- Tests: `tests/AcDream.Runtime.Tests/` (table completeness ×53, auto-save
split ×53 vs lane B §2's byte-verified column, local-write-then-send on
BOTH adapter paths, dirty/flush state machine, unknown-id rejection);
`tests/AcDream.Core.Net.Tests/` blob conformance: golden byte vector +
round-trip through `PlayerDescriptionParser` (lane C §9 S-c — the CH3
builder died of green tests pinning a wrong shape; the golden vector is
non-negotiable).
**Register rows (same commit):** `0x34` mapping ACE-sourced (D3); the
480 s autosave if any part is deferred (target: not deferred);
`GetDefaultOptionValue @0x005D2A30` vs ctor-default disagreement recorded
when the client-default column lands (lane C §8.2 — reproduce, don't fix).
**Acceptance:** build + FULL Release suite green; blob golden vector
byte-exact; both adapters share one code path for the local write.
### OP2 — the two widget primitives + remaining UIOption mappings
**Files.**
- Modify `src/AcDream.App/UI/Layout/LayoutImporter.cs` +
`src/AcDream.App/UI/Layout/ElementReader.cs`: read tab-table property
`0x2E` (struct array `{0x30 button, 0x31 page, 0x32 isDefault}`) and
ListBox template-list property `0x64` (entries `{0x63 layout DID,
0x62 element id}`) into `ElementInfo`.
- Create `src/AcDream.App/UI/UiTabPanel.cs` (element Type 8 — retail
`UIElement_Panel`; renamed from this plan's original `UiTabControl` at the
OP2 rework, and a dormant `UiDatElement` subclass per AD-73): tab-button
↔ page-slot switching per lane A §5; default tab honoured.
- Create `src/AcDream.App/UI/UiTemplateListBox.cs` (element Type 5 with
authored template list): `AddItemFromTemplateList(index)` instantiates a
row from the authored template layout/element via `DatWidgetFactory`,
scrollbar named by `P0x72`.
- Modify `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`: map Types 5/8 and
`UIOption_Slider 0x10000037`, `UIOption_Menu 0x10000038`,
`UIOption_CheckboxSlider 0x10000036`, `UIOption_CheckboxBitfield64
0x10000044` (LED checkbox `0x10000035` already maps to `BuildCheckbox`).
- Modify `tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs`
(`Layouts`, `:17`): add `0x2100002B`, `0x2100002A`, `0x21000028`,
`0x2100005C`, `0x21000029`. **Coordinator runs the generator**
(`ACDREAM_REGENERATE_UI_FIXTURES=1`, serial tree) and commits fixtures.
- Conformance tests pin: the tab table (4 entries, Gameplay default), all
three template arrays (lane A §10.1), the Character ListBox's 6-header /
49-toggle authored row build, scrollbar linkage.
**Acceptance:** build + FULL suite; fixtures committed and pinned; no
change to any existing widget's behaviour (the S2/AP-192 outline seams from
`aa6635ae` must be preserved in new widget builds).
### OP3 — panel shell + open paths + Gameplay tab (first vertical)
**Files.**
- Create `src/AcDream.App/UI/Layout/OptionsPanelController.cs`: mounts
`0x2100002B` in host `0x2100006E` slot `0x1000018D` (stack key 10) via
`RetailWindowManager`; tab control wiring; close button fires
`0x1000001A`.
- Create `src/AcDream.App/UI/Layout/OptionPageModel.cs`: the
`OptionPage`/`PlayerOptionPage` model — per-page option array of
`(current, saved, default)` triples + verbs Apply/Reset/Defaults with
retail's exact semantics (lane A §10.4): LED click applies immediately
(`SetCurrentValue → Apply(1)`); Apply commits baseline + `SaveOptions`
flush; Reset reverts to baseline; Defaults applies live without
committing and is never disabled; Apply/Reset disable when clean; hide →
revert uncommitted; show → apply + commit. Pure logic, unit-tested
without DAT.
- Input action `ToggleOptionsPanel` (`0x1000001A`, F11) in
`src/AcDream.UI.Abstractions/Input/` (`KeyBindings.RetailDefaults()`
the ONLY production table; #358's lesson) +
`src/AcDream.App/Input/GameplayInputCommandController.cs` routing +
toolbar button `0x1000019B` (already authors `P0x12`).
- Gameplay tab (`0x2100002A`, class `gmGameplayOptionsUI`), seven buttons
per D5/D6 and lane D §1: Exit Game → the existing graceful-close path;
Exit to Char Selection → retail confirm dialog (`RetailDialogFactory`) +
mid-air refusal via the interface-text seam, then D6's Exit-Game
behaviour; Configure Keyboard → opens OP8's screen; until OP8 lands the
button is INERT (authored, clickable, no handler — no invented text, no
stub screen), the OP3 gate script says so explicitly, and OP8's gate
re-tests it (the campaign cannot close with the button inert); Use Mouse Turning Settings → the six-option macro
(lane D §4.4) with its six retail chat lines, camera-mode consumer
verified against the camera digest in-slice (register row if the mode is
absent); In-Game Help / Urgent Assistance / Report Abuse per D5.
**Register rows:** Exit-to-char-select adaptation (D6); UA/RA dead-URL
short-circuit (D5); help-plugin behaviour (D5); mouse-turning consumer row
if needed.
**Gate:** connected — panel opens via F11 AND toolbar; tabs switch with
Gameplay default; the seven buttons behave per contract; window drags /
resizes / stacks like the CH6 floaties.
### OP4 — the Character tab
**Files.**
- Create `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`:
binds layout `0x21000028` root `0x100001F9` ListBox `0x100001FA` through
OP2's template mechanism; 6 headers + 50 toggles (D3) in lane B §2's
authored order; every row bound by `PlayerOption` id through OP1's table
and the shared seam (auto-save ids → `0x0005` on click-apply; batched ids
→ dirty + blob per OP1).
- Defaults: extract the DAT `DBPropertyCollection`
(`GetDIDFromEnumStatic(0x16, 2)`, §1 U1-closure) via `DatCollection` at
import; conformance-pin extracted values; cross-check overlapping ids
against lane B/C's byte-verified default words and RECORD any
disagreement as a finding (never silently pick).
- Consumers (lane B §7.1): group B one-line binds — timestamps + filter
language at `RuntimeCommunicationState.AddText`; daylight
(`ForcedDayGroupIndex`), weather, fog; run-as-default in
`RuntimeLocalPlayerMovementState`; main-pack default at the pickup path;
the UI-display bits at their existing retained controllers. Group C
re-pointing per D7. Group A wire+store only. Group D register rows.
- Conformance test: all 50 rows ↔ table ↔ storage bit ↔ wire route pinned
in both directions (the CH4 registry-conformance pattern — an invented
row or a dropped row fails the build).
**Register rows:** group D deferrals (salvage, housing, fellowship-share
field, PK-deaths already rowed in OP1, mouse-turning row lives in OP3);
each group-C re-point that changes an observable default.
**Gate:** connected — LED rows toggle + persist across relogin (server
echo), timestamps/daylight/fog/weather/run-default observably switch, Apply
/ Reset / Defaults exercise retail semantics, tab-switch reverts
uncommitted edits.
### OP5 — the Chat tab
**Files.** Create
`src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` binding
`0x2100005C` root `0x1000050A`: the two LINKED opacity sliders (bound to
the existing `RetailWindowOpacityController` values through
`ChatOpacityLink` — AP-190's model, now user-reachable) and the five
per-window filter blocks (SetUserData ids 8/2/3/4/5; main window 12 rows,
floaties 13 — lane A §8's byte-decoded masks) writing the per-window filter
state CH6 already consumes (`ChatWindowState.ShouldDisplay`). The
`0x1000008C` per-window blob stays local-only (already-anticipated register
row from `2026-08-09-chat-retail-window-shell.md` §6.3 — cite, don't
duplicate).
**Gate:** connected — filter checkboxes change window routing live;
opacity sliders drive the focus fade; settings survive relogin locally.
### OP6 — the Config tab
**Files.** Create
`src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` binding
`0x21000029` root `0x100001FF`: all 27 rows in lane A §9 / lane D §7.3's
authored order, backed by acdream's client settings store
(`%LOCALAPPDATA%\acdream\` — the D1 home). Rows with live subsystems bind
now: the three volume trios → the audio pipeline, "play sound only when
active", mouse-look sensitivity + invert Y, FOV, chat font size/face if the
chat pipeline exposes them. Rows without a subsystem (resolution +
fullscreen + sync, degrades, texture detail family, multi-pass alpha)
persist store-only under ONE register row enumerating them (the goal's
"honest store-only handling"); resolution's `SetConfirmChange` flow ships
whenever the consumer lands, not now.
**Gate:** connected — audio sliders audibly change mix; mouse sensitivity
observably changes; store-only rows persist across relaunch.
### OP7 — headless `characterOptions`
**Files.** Modify
`src/AcDream.Headless/Configuration/HeadlessConfiguration.cs` +
`HeadlessConfigurationLoader.cs`: optional `characterOptions` block, strict
— keys are exactly the lane B §5.2 tier-1+2 option NAMES, values bool;
unknown key = load failure (D8). Modify the session host
(`src/AcDream.Headless/Hosting/`) to diff declared vs
PlayerDescription-seeded state after LoginComplete and send changes through
the shared seam (auto-save ids as `0x0005`, remainder via one
`SaveOptions` blob), honouring #368's one-dedicated-update-thread contract.
Idempotent on reconnect (retail itself no-ops unchanged options — lane C
§3.5). Headless tests: schema rejection, diff-only sends, reconnect
idempotence, thread affinity preserved.
**Gate:** automated + one live bot-vs-local-ACE run (no graphical client)
showing declared options land and survive reconnect.
### OP8 — Configure Keyboard
**Files.** Create
`src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (+ a
`src/AcDream.Core/...` DAT `ActionMap` (DBO type `0x27`) reader if
`DatCollection` lacks one): `gmKeyboardUI`'s six ActionClass list boxes
via OP2's Type 5 widget, rows = label + tooltip + N key buttons + Clear
from the DAT master maps (DIDs `0x14000000`/`0x14000002` — the exact
enum→DID pairing is lane D unknown #4, resolved in-slice by dumping both),
merged with live `KeyBindings`; left-click key button → `InputDispatcher`
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:** `.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.
### OP9 — closeout
- Retire `SettingsPanel`/`SettingsVM`'s IPanel surface +
`SettingsDevToolsComposition` wiring + `DevToolsGameplayCommands`
no-ops; delete `GameplaySettings`' 16 server-shadowed booleans (lane B
§7.2.5), re-scoping true client-only settings into the D1 store. Every
deletion checked against consumers; no behaviour regression.
- Bookkeeping: plan status flips, ISSUES sweep (#358 retest against OP8's
screen — its Ctrl+M lesson lives in `RetailDefaults()`), register
reconciliation, CLAUDE.md Current-state paragraph (per
`feedback_claude_md_staleness`), memory digest update
(`project_chat_digest` addendum or a new settings digest).
- Write `docs/research/2026-08-10-campaign-op-test-script.md`: the
connected-gate script covering every OP3OP8 gate item, per-tab, with
expected retail behaviours — the campaign's stop condition is this
script ready plus all slices code-complete.
**Gate:** the user's final connected matrix (their eyes, their pace).
---
## 5. What is explicitly OUT of scope
- Packing the `0x200` GameplayOptions blob section (per-window chat state
on the wire) — a follow-on (CH6f shape), pre-anchored by lane C U1 and
`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).
- 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).
## 6. Verification discipline
Per commit: `dotnet build` + FULL Release suite green (baseline at plan
time: 12,611 / 4 skips / 0 failures at `29138430`+). Golden byte vectors
for every wire builder. Conformance pins for every authored inventory
(row lists, template arrays, tab table, checkbox masks). Register rows in
the SAME commit as the deviation. No user-visible placeholder text ever —
all user-facing strings resolve from the DAT string tables
(`0x23000001`/`0x23000003`) by `compute_str_hash` name, never hard-coded
English.
## 7. Review protocol
Dual-lens Opus review per slice (mechanism-faithfulness lens ×
regression/blast-radius lens), fixes applied by the implementer; REJECT →
focused re-review; TWO failures → Fable fixes directly (user-directed
2026-08-10). Review findings that feed both a fixer and a re-reviewer are
persisted to a committed findings doc BEFORE dispatch.
## 8. Process rules (binding, inherited from Campaign CH via the handoff)
Max 34 agents in parallel INCLUDING children; every agent prompt carries
an explicit no-subagent clause; ONE builder/tester on the tree at a time
(read-only research may overlap); agents never launch the graphical
client; the user runs all connected gates; screenshots transcribed into
docs immediately; ledger placeholders anchored per-row, post-amend SHAs
recorded by the coordinator; decomp claims byte-verified against the
PDB-paired binary (`check_exe_pdb.py` MATCH first); stalled agents resumed
via SendMessage before any redo; agent claims spot-verified at the seams
before anything builds on them.
## 9. Ledger
| Slice | Status | Commit(s) | Review | Gate |
|---|---|---|---|---|
| OP1 | CLOSED | `86c0a7e0` + fixes `09029f9f` + residuals `6f48e341` | dual APPROVE-WITH-FIXES (`2026-08-10-op1-review-{mechanism,blast}.md`) → re-review CLOSED (`2026-08-11-op1-rereview.md`); residuals R1/R2/R3 landed | automated only — n/a |
| OP2 | CLOSED | `df9c7a35` (REJECTED) → rework `b236a442` → closure (this commit) | dual REJECT (`2026-08-11-op2-review-{mechanism,blast}.md`) → re-review blast CLOSED / mechanism REOPEN-on-one (`2026-08-11-op2-rereview-{mechanism,blast}.md`) → coordinator closure: AP-195 filed, AD-73 addendum, tooltip port, zero-children pin | automated only — n/a |
| OP3 | CODE-COMPLETE, gate READY | `9d26ecc6` → fixes `386076af` → residuals `cb334690` | dual APPROVE-WITH-FIXES (`2026-08-11-op3-review-{mechanism,blast}.md`) → re-review REOPEN-narrow (`2026-08-11-op3-rereview.md`) → coordinator residuals landed | connected gate OWED (script §OP3) |
| OP4 | CODE-COMPLETE, gate READY | `22b86b9f` → fixes `bc43fb1d` → residuals `ac0304dc` | dual APPROVE-WITH-FIXES (`2026-08-11-op4-review-{mechanism,blast}.md`) → re-review REOPEN-narrow (`2026-08-11-op4-rereview.md`) → coordinator residuals landed | connected gate OWED (script §OP4) |
| OP5 | CODE-COMPLETE, gate READY | `e71e5a96` (AP-195 retired) → fixes `6d0b0f92` → residuals `67b0815c` | combined APPROVE-WITH-FIXES (`2026-08-11-op5-review.md`) → re-check CLOSED (`2026-08-11-op5-recheck.md`) → coordinator drag residuals landed | connected gate OWED (script §OP5) |
| OP6 | CODE-COMPLETE, gate READY | `f5ac1742` (REJECTED) → rework `472525b9` → doc residuals (coordinator) | REJECT (`2026-08-11-op6-review.md`) → re-review CLOSED, all six caption sites byte-decoded (`2026-08-11-op6-rereview.md`) | connected gate OWED (script §OP6) |
| OP7 | CLOSED | `09cb548a` → fixes in `7b60e71b` (shared commit, see its message) | combined-lens APPROVE-WITH-FIXES (`2026-08-11-op7-review.md`); all nine findings closed | live bot-vs-ACE gate PASSED 2026-08-11 (coordinator; evidence in script §OP7) |
| OP8 | CODE-COMPLETE, gate READY | `b4edee97` (REJECTED) → rework `b1968ce9` → residuals `f1d50207` → merge `1c5cd969` | dual REJECT (`2026-08-11-op8-review-{mechanism,blast}.md`) → rework → re-review REOPEN-narrow (`2026-08-11-op8-rereview.md`) → coordinator round-2 residuals (inert-row conflict exclusion, DAT-default display, injectivity pin; #373 filed) | connected gate OWED (script §OP8) — merged onto the campaign tip AFTER `057d8cd7` per the re-review's merge note, so the #372 viewport fix covers OP8's six ListBoxes |
| OP9 | CLOSED | `371197a3` → review residuals `07f2b3f7` | combined-lens APPROVE-WITH-FIXES (`2026-08-11-op9-review.md`, `289bf5bc`): MUST-FIX 1 (SaveAudio→ApplyAudio live-apply lost its only assertion — restored + failure-ordering pin), SF-2 (code-structure.md seam list), SF-3 (dead residues: private SaveCharacter, ISettingsStorage.SaveCharacter, IngressShutdownRoots.Settings — deleted; SettingsStore's public SaveCharacter kept as the tested storage API), SF-4 (test-delta was 84 = 94 removed/10 added, not the commit message's "80 exactly"; no live-behavior test in the gap besides MUST-FIX 1's), SF-5 (dangling comment), NIT 6 (AP-196 channel attribution corrected in-register) — all closed by coordinator residuals | closeout gate = the user's final connected matrix over §OP3§OP8 |

File diff suppressed because one or more lines are too long

View file

@ -1,860 +0,0 @@
# Campaign LA — launcher / installer / updater + retail character-select
**Status:** ACTIVE (started 2026-08-14)
**Spec (approved):** `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`
**Memory crib:** `claude-memory/project_launcher_direction.md`
**Branch:** `claude/acdream-launcher-credentials-4d2f7c` (merge to main at coherent checkpoints)
Campaign LA ships the alpha launcher (Avalonia, Windows + Linux): triple-duty
launcher + installer + updater, ThwargLauncher-model profiles with full in-UI
CRUD, plaintext credential file (user-decided), file-contract orchestration of
`AcDream.App` and `AcDream.Headless`, plugins + login commands on both hosts,
the headless character probe, and the retail character-select screen (no
Create). All architectural decisions live in the spec — this plan sequences
the work.
## Process (binding)
- **Fable plans/sequences/integrates. Sonnet implements bounded slices. Opus
reviews at every slice boundary, dual-lens:** (a) architectural — ownership,
layering, dependency-guard integrity, seams; (b) retail fidelity vs
`docs/research/named-retail/` wherever the slice touches retail behavior.
Findings → fixes → narrow re-review.
- Max 34 agents in parallel including children; subagents never spawn
subagents; implementer prompts carry spec+plan paths, files-to-read,
acceptance criteria, commit style.
- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per
slice tagged `Campaign LA`; retail deviations add their
`docs/architecture/retail-divergence-register.md` row in the same commit;
no workarounds without explicit user approval.
- Connected/visual gates are the ONLY stop-and-wait points; each gets an
exact script under `docs/research/` and non-blocked slices keep moving.
## Slice map
| Slice | Deliverable | Depends on |
|---|---|---|
| LA0 | `AcDream.Platform` extraction (`ApplicationPathSet`) + guard amendments | — |
| LA1 | Launch contract: App `--session-config` + stdin credential; status.jsonl writer both hosts; roster plumbing | LA0 |
| LA2 | Headless probe mode + `idle` policy | LA1 |
| LA3 | `AcDream.Launcher.Core`: profile store CRUD, config composition, spawn/supervise, status reader | LA0 (LA1 contract shapes) |
| LA4 | `AcDream.Launcher` Avalonia UI: CRUD views, per-char settings, sessions, probe action | LA3 |
| LA5 | Plugin hosting: headless `IPluginHost` + capability flag; session-driven plugin set both hosts | LA1 |
| LA6 | Login commands: parser-core extraction + execution on both hosts | LA1, LA5 |
| LA7 | Character-select: Runtime selection state + wire (delete/restore/error) + no-selector flow | LA1 |
| LA8 | Character-select authored retail screen (flat listbox — NO 3D preview, recon-corrected) | LA7 |
| LA9 | Installer: first-run wizard (DAT locate/validate, bake w/ progress, SHA record) | LA3, LA4 |
| LA10 | Updater: GitHub Releases manifest, download/verify/install/swap, self-update | LA3, LA4 |
| LA11 | Closeout: connected-gate script, roadmap/CLAUDE.md/memory, program ledger | all |
Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5LA8 (client
side) — different assemblies, no shared files. LA9/LA10 close the launcher
side; LA11 closes the campaign.
## Linux posture (binding — user decision 2026-08-14)
Everything the launcher does must WORK ON LINUX in this campaign, except
GUI client launches: the Linux graphical client is Slice L, parked at L1,
resuming later ("ok we will do it later"). Concretely:
- **Linux-shipping in LA:** the Avalonia launcher UI, profile CRUD +
0600-permission file, installer (manual DAT picker — the auto-detect
paths are Windows-only; `acdream-bake` is GL-free and runs on Linux),
updater (staged swap; Linux can replace a running binary but keep the
same staged-atomic flow), headless launches with plugins + login
commands, and the character probe.
- **Launcher UX on Linux:** the `gui` / `guiSelect` launch modes render
disabled with an explicit "requires the Linux graphical client (Slice
L)" note — never a silent failure.
- **Per-slice enforcement:** every slice touching Launcher.Core, Headless,
Runtime, Bake, or Platform runs its test projects on Linux (native
Ubuntu or WSL, matching the K-slice practice) before the slice is DONE;
LA4/LA9/LA10 additionally prove a real `linux-x64` self-contained
publish. LA11's connected-gate script gets a Linux section: launcher on
Ubuntu doing CRUD, probe, headless launch with plugin + login commands,
first-run install with a manual DAT path, and an update swap.
- When Slice L later ships, the launcher's Linux GUI modes light up with
NO launcher changes (the session-config contract is host-agnostic) —
that expectation is part of LA's design acceptance.
## LA0 — `AcDream.Platform` extraction
New BCL-only project `src/AcDream.Platform/` holding `ApplicationPathSet` +
`IApplicationPathEnvironment` (today
`src/AcDream.Runtime/Platform/ApplicationPathSet.cs` — self-contained, no
intra-Runtime dependencies; clean cut). Runtime/App/Headless reference it.
Recon facts (2026-08-14): blast radius is the definition, six source files
(`GraphicalHostPlatformServices.cs`, `GraphicalLegacyConfigurationMigrator.cs`,
`App/Program.cs`, `GameWindow.cs:533`, `HeadlessPathSet.cs`,
`HeadlessPlatformEnvironment.cs`; two more files are doc-comment-only), two
test files (`ApplicationPathSetTests.cs` moves to a new
`tests/AcDream.Platform.Tests/`;
`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and the dependency
guards — CORRECTED post-review (the original recon here asserted the wrong
guard, the C4-closeout failure mode): the K0 Headless guard
(`HeadlessAssemblyReferencesOnlyTheRuntimeProject`) asserts HEADLESS's own
csproj reference list, which this move does not touch — it stays UNCHANGED;
the guard that actually needs amending is Runtime's own
`RuntimeDependencyBoundaryTests.RuntimeProjectDeclaresOnlyApprovedProjectDependencies`
(Runtime gains the `AcDream.Platform` reference), amended with a cited
comment in the same commit. Namespace stays `AcDream.Runtime.Platform`?
NO — rename to `AcDream.Platform` and fix the eight usings (clean naming beats
avoiding a mechanical edit). Register new projects in `AcDream.slnx`.
**Acceptance:** build + full test suite green; guard test asserts the new
exact reference set; launcher-side consumability proven by the LA3 project
referencing only `AcDream.Platform`.
## LA1 — launch contract (client side)
### Pinned launch-contract schema (v1, BINDING — committed per LA3 review)
This text is the single source of truth for the launcher↔host file
contract. Both host readers (LA1), the composer (LA3), and the probe
loader (LA2) implement EXACTLY this; any change is an amendment to THIS
section first, implementations second. The LA1+LA3 merge adds a
cross-assembly test feeding a composer-produced document to both host
loaders — that test is the seam's permanent enforcement.
Session-config document (System.Text.Json, camelCase,
`UnmappedMemberHandling.Disallow`, camelCase string enums):
```json
{
"version": 1,
"process": {
"content": { "datDirectory": "...", "preparedAssetPath": "..." }
},
"sessions": [{
"id": "sess-1",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "testaccount",
"mode": "probe",
"character": { "id": 1342177290 },
"policy": { "id": "idle" },
"credential": { "provider": "standardInput", "reference": "session" },
"plugins": ["ExamplePlugin"],
"loginCommands": ["/vt start"],
"loginCommandDelayMs": 500,
"statusFile": ".../launcher/sessions/sess-1/status.jsonl"
}]
}
```
Field rules:
- `process.paths` is OMITTED unless a caller genuinely supplies overrides
(never an empty object — the App reader has no `paths` member and
strict parsing rejects unknown keys; LA3 review finding 1).
- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe
(connect → characterList → graceful disconnect, no EnterWorld). The
headless loader accepts the field starting at LA2.
- `character`: exactly ONE of index|id|name; OMITTED entirely (not null)
for guiSelect and for probe sessions.
- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted
for gui/guiSelect/probe.
- `credential`: always `{ "provider": "standardInput", "reference":
"session" }` for launcher-composed configs.
- `plugins`: absent/null means load all discovered plugins (preserving the
developer flow); explicit `[]` means load none. Launcher-composed
normal-empty and probe sessions emit `[]` so they cannot load arbitrary
machine-local plugins.
- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
omitted-when-unset (never null, never `[]` for empty). Absent
`loginCommandDelayMs` means 500.
Status stream (`statusFile`, one JSON object per line, writer flushes per
line, writer opens `FileShare.Read`, tailer opens
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
`pluginFailed{plugin,error}`,
`loginCommandFailed{commandIndex,command,error}`,
`characterCreated{guid,name}`, `creationFailed{code,reason,name}`,
`disconnected{reason}`,
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
sides. Unknown `e` values must parse to a typed Unknown event, never
throw; a known `e` with a wrong payload shape should be distinguishable
from an unknown `e` (LA3 review finding 12).
**Campaign CC CC2 amendment (this section is the contract; the writer and
tailer below implement it, in that order):** `characterCreated{guid,name}`
fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request —
`guid`/`name` come straight off the shared `0xF643`
`CharGenVerificationResponse` Ok identity payload
(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately
named `guid`/`name` rather than `characterId`/`characterName` to mirror
that payload's own field names and to read distinctly from
`enteredWorld` — a freshly created character is logged straight in by
retail without a fresh `characterList` (see that type's doc comment), so
`characterCreated` can precede an `enteredWorld` for the same character
rather than replacing it. `creationFailed{code,reason,name}` fires on any
non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code`
value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a
reader gets a stable readable reason without hard-coding the numeric
mapping itself, and `name` is the ATTEMPTED character name so a launcher
can render "the name Bob is taken". (CC2 review F4: the enum member
originally rode the `name` key, colliding in meaning with
`characterCreated.name`; renamed before any consumer shipped.)
`loginCommandFailed.commandIndex` is the zero-based index in the configured
`loginCommands` array. `command` is the exact configured line and `error` is
the isolated parser/router/handler failure. The event is observational: the
host continues with the next configured line and never converts the command
failure into a login, plugin, session, or process failure.
**Known LA1 status limitation:** the stream has no independent mid-play
wire-drop detector. If a transport becomes silent without raising through the
host's tick/teardown path, no immediate `disconnected` line can be promised;
the launcher must not treat the absence of that line as proof that the socket
is healthy. Explicit reconnect is ordered and observable — it emits
`disconnected{reason:"reconnect"}` before the replacement connection's second
`connected` — and normal stop/process teardown closes any still-open
connection before `exited`. A future transport-health signal may improve the
timing without changing this pinned event vocabulary.
Three pieces, one slice, because they share the session-config/status seam:
1. **App `--session-config <path>`:** parsed once in `Program.cs` into
`RuntimeOptions` (code-structure rule 4); carries endpoint, account,
optional character selector, `Plugins`, `LoginCommands`, `Content`
(DatDirectory/PreparedAssetPath), status-file path, credential reference.
Recon: `Program.cs` has NO subcommand dispatch today — args handling is
one positional DAT-dir (`Program.cs:35`), so the flag is purely additive
(preserve the positional arg). The live-credential seam is a single call
site (`SessionPlayerComposition.cs:1128-1135`
`LiveSessionConnectOptions`); the config path populates the same
`RuntimeOptions` fields from a different source. Env-var dev flow
untouched. App gains the `StandardInput` credential read (mirroring
`HeadlessCredentialResolver.ResolveStandardInput` — one line, immediately
wrapped in an erasable secret, redacted `ToString`; today
`RuntimeOptions.LivePass` is a bare string — the config path must not
widen that exposure).
2. **Status stream both hosts:** per-session `status.jsonl` (path given in
config; absent → permanent no-op sink). Versioned event
vocabulary (`"v":1`): `started`, `connected`, `characterList`,
`enteredWorld`, `pluginLoaded`/`pluginFailed`,
`loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC
CC2), `disconnected`, `exited`.
Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL
sink with four kinds (lifecycle/failure/event/resources) and NO per-session
file — the status writer is a second, separate sink, not a rework of the
diagnostics writer. App has no structured writer today; it gets the same
shared implementation (lands in Runtime so both hosts borrow it).
3. **Roster plumbing:** `CharacterList.Parsed` is consumed inside
`LiveSessionController.StartCore` (`LiveSessionController.cs:612`) and
never escapes — add a typed roster report on the lifecycle-host seam
(`ILiveSessionLifecycleHost`) so hosts can emit the `characterList` status
event and (later) the char-select screen can populate. No behavior change
to selection itself in this slice.
**Acceptance:** round-trip tests (config → `RuntimeOptions`; stdin credential;
status events in order with exact shapes; roster surfaced); App/Headless/
Runtime suites green; redaction test proves the password never appears in
status/diagnostics output.
## LA2 — headless probe mode + `idle` policy
Recon facts: the probe's shape already exists as the `NoCharacters` early-exit
(`LiveSessionController.cs:613-622``StopCore()` → 4-stage
`SessionScope.DrainTeardown`, graceful, `_inWorld == false` so no pre-logoff
flush) — but it fires only on selection FAILURE and maps to exit code 5
(`HeadlessProcessHost.RunOnUpdateThread:203-212` treats any non-`Connected`
start as `ConnectionError`).
1. **Probe:** a `Probe` flag on the connect options short-circuits `StartCore`
right after `GetCharacters` (before `TrySelectCharacter`): report roster,
`StopCore()`, return a NEW `LiveSessionStartStatus.ProbeComplete`.
`HeadlessProcessHost` maps it to exit code 0 with a final `characterList` +
`exited(reason: "probe")` status pair. Config: `mode: "probe"` on the
session descriptor relaxes the `JsonRequired` character selector + policy
for probe sessions ONLY (loader keeps strict validation otherwise —
recon: violations currently surface as raw `JsonException` → exit 3; probe
relaxation must be shape-level in the loader, not attribute removal).
2. **`idle` policy:** new consumer `HeadlessBotPolicy` id — enter world, run
plugins/login-commands (arrive in LA5/LA6), stay until stopped, clean
SIGINT teardown (K4's graceful-logout path already proves the mechanism).
**Acceptance:** probe test (fixture session → roster event → graceful teardown
receipt → exit 0, no `EnterWorld` on the wire); loader tests for probe-shape
relaxation + strict normal validation; idle-policy lifecycle test; suites
green. Connected verification (user gate, LA11 batch): live probe against ACE
twice in a row with no lingering session (spec §11.9).
## LA3 — `AcDream.Launcher.Core`
New BCL-only project + `tests/AcDream.Launcher.Core.Tests/`. References
`AcDream.Platform` ONLY.
- Profile store: `launcher-profiles.json` (spec §5 schema) — load/save/
validate, full CRUD operations, roster merge (fold `characterList` events
in, preserving per-character user settings), 0600 on Linux.
- Session-config composition: profile + install records → the LA1 config
shape (typed writer; probe shape included). Passwords excluded — stdin only.
- Process orchestration: spawn App/Headless per launch mode, feed password to
child stdin then close, supervise lifetime, tail `status.jsonl`
(share-tolerant reads), surface typed session state.
- SHA-256 utility (pak record + download verify — consumed by LA9/LA10).
**Acceptance:** CRUD/round-trip/merge tests; composition tests (all three
modes + probe); supervision tests against a fake child process (echo script);
status-tail tests including partial-line handling; suites green.
## LA4 — `AcDream.Launcher` (Avalonia)
New Avalonia project (Windows + Linux). MVVM over Launcher.Core; no game
solution references beyond `AcDream.Platform` transitively.
- Views: server list → accounts → characters tree; add/edit/remove dialogs
for servers (name/host/port) and accounts (account + password entry);
per-character settings editor (launch mode, plugin set, login commands);
per-account "refresh characters" (probe); running-sessions status column.
- Launch actions per mode (`gui` / `guiSelect` / `headless`); probe disabled
while the launcher runs a session for that account.
- First-run wizard shell + update prompt shell (bodies land in LA9/LA10).
**Acceptance:** ViewModel tests in Launcher.Core.Tests patterns (VMs live in
the Avalonia project but stay logic-thin; anything testable pushes down);
build green on Windows; `linux-x64` publish compiles. Visual polish is gated
at LA11 (user).
## LA5 — plugin hosting on both hosts
Recon facts (2026-08-14): `PluginLoader`/`PluginDiscovery`/`PluginManifest`
already live in `AcDream.Core` (Headless-reachable). App's single load loop
(`App/Program.cs:110-121`) loads ALL discovered plugins from two roots
(`AppContext.BaseDirectory/plugins` + `ApplicationPathSet.PluginsDirectory`,
dup-id skip) — no allow-list exists on either host. `AppPluginHost` is a
26-line pass-through; three of four `IPluginHost` surfaces (`State`
`WorldGameState`, `Events``WorldEvents`, `Selection``SelectionState`)
are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is
genuinely App-only. Headless has zero plugin hosting today (confirmed).
1. Session-config `Plugins` allow-list filters the discovery result on BOTH
hosts (absent/null list = load all, preserving today's dev behavior;
explicit `[]` = load none). Launcher-composed normal-empty and probe
sessions emit `[]`.
2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned
`State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new
capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect
headless. Contract documented in `Plugin.Abstractions`.
3. `pluginLoaded`/`pluginFailed` status events from both hosts' load loops.
**Acceptance:** fixture plugin in Headless suite (load, capability flag,
markup no-op, teardown via collectible ALC); allow-list filter tests both
hosts; status events asserted; suites green.
## LA6 — login commands on both hosts
Recon facts (2026-08-14): the command core is dependency-CLEAN —
`ChatInputParser` (zero usings), `ChatCommandRouter` (BCL +
`AcDream.Core.Chat`), `RetailClientCommandCatalog` (FrozenDictionary),
`ChatVM` (Core.Chat/Combat + `System.Numerics` only), `ICommandBus` + the
four command records (BCL-only). The block is assembly identity, not
coupling. `ChatCommandRouter.Submit`'s two entanglements: a hard `ChatVM`
parameter (uses only `ShowInterfaceText`/`ShowSystemMessage`/
`LastIncomingTellSender`/`LastOutgoingTellTarget`) and the `ICommandBus`,
whose production implementation (`LiveSessionCommandRouter`,
`App/Net/LiveSessionCommandRouter.cs`) is App-only and wraps wire-send
delegates from the live session. GUI already has a login-command analog:
`RetailUiAutomationScriptRunner` feeds `ChatCommandRouter.Submit` at
`RetailUiRuntime.cs:523-527`.
1. **Extraction:** move parser/router/catalog + `ICommandBus` + the four
command records (+ sibling tables they require) into Runtime
(`AcDream.Runtime/Chat/...`); the router's `ChatVM` parameter becomes a
narrow feedback interface defined beside it (exactly the four members
used); `ChatVM` (stays in UI.Abstractions) implements it. GUI path stays
bit-identical — same call sites (`ChatWindowController.cs:326`,
`FloatingChatWindowController.cs:157`), same routing, CH-accepted
behavior regression-checked by the existing chat suites.
2. **Headless dispatch:** a Runtime/Headless `ICommandBus` binding the same
session send delegates (`SendTalk`/`SendTell`/`SendChannel`/
`SendTurbineChat`) + Runtime state that App's router binds — paralleling
`LiveSessionCommandRouter`'s registrations, feedback lands in
`RuntimeCommunicationState.AddText`.
3. **Execution:** both hosts run `LoginCommands` sequentially as-if-typed
(default 500 ms inter-command delay, config-overridable) once
entered-world; per-command failures → status stream, never abort.
4. K0 guard: if the code folds into Runtime, the single-reference assertion
stands untouched; the forbidden-prefix closure tests keep passing. Any
guard text change is deliberate and documented.
**Acceptance:** extraction lands with zero GUI chat test regressions
(UI.Abstractions + App chat suites bit-green); headless executes a
login-command script against a fixture session with ordered wire sends;
delay + failure-tolerance tests; suites green.
## LA7 — character-select: state + wire
Recon facts (2026-08-14): retail's screen is `gmCharacterManagementUI`
(`acclient.h:56545`) — flat listbox + Create/Enter/Delete/Restore buttons +
dialog contexts. **No 3D preview exists on retail's select screen** (the
`gmCG3DView`/`CreatureMode` viewport is chargen-only; the old
"rotating pedestal" line in `retail-ui/05-panels.md` §13 is uncited and
wrong). Our `CharacterList` parse already matches ACE's serializer exactly
(two-array shape, status/deleted always zero from ACE) and the two-phase
enter-world (0xF7C8 → 0xF7DF → 0xF657) is implemented. Missing wire:
delete/restore/error.
1. **Wire messages** (`AcDream.Core.Net/Messages/`, retail citations in
file docs per house style): `CharacterDelete` 0xF655 — outbound
account String16L + **slot index** (`Proto_UI::SendDeleteCharacter
@0x00546b30`; NOT guid), inbound opcode-only ack followed by a fresh
CharacterList; `CharacterRestore` 0xF7D9 guid-only (ACE + holtburger
consensus; the decomp's apparent extra strings are a decompiler
artifact — spec §11.4), response 0xF643 (flag + guid + name +
secondsDisabled); `CharacterError` 0xF659 parser (new — today NO
character-stage server error can be surfaced).
2. **Runtime selection state** (J-owner pattern): roster with per-entry
greyed/pending-delete state (`SecondsGreyedOut != 0` ⇒ pending; ACE
sends a constant 1 during the grace window — treat as boolean, never a
countdown), highlight, pending-delete dialog state, typed commands
(highlight / enter / delete-request / delete-confirm / restore).
Retail behavior oracles: `RebuildCharacterList@0x004ec3a0`,
`SelectCharacter@0x004ec160`, `UpdateButtons@0x004ec240`
(Delete↔Restore swap on greyed state), `EnterGame@0x004ed440`.
3. **No-selector flow:** a graphical session config without a character
selector stops at selection state instead of auto-enter; the
first-available fallback (`CharacterList.TrySelectFirstAvailable`,
used at `LiveSessionController.cs:848-851`) remains ONLY for
selector-carrying/headless sessions. Selection feeds the existing
`EnterWorld` path unchanged.
LA7b hazards carried from the LA7a review (2026-08-14): ACE's restore
handler has a SILENT no-reply path (unknown guid → `return;`, no 0xF643,
no 0xF659) — selection state must never block awaiting a restore reply;
outbound routing is delete via retail's SendToLogon, restore via
SendToControl, ACE replies on UIQueue; `charError.NumErrors` (0x19) is an
enum-range sentinel and must never render as a user-facing message.
Register row AD-97 (guid-only restore request, an adaptation) rides the
LA7a branch.
**Acceptance:** message round-trip tests against ACE's serializer shapes;
selection-state tests (greyed transitions, delete→list-refresh, restore,
error surfacing); no-selector stop + enter flow tests; suites green.
## LA8 — character-select: authored retail screen
Scope: project LA7's state through the REAL retail screen. No 3D preview
(recon-corrected; a preview would be an unapproved divergence).
1. **Layout resolution:** retail resolves the root via
`UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a)` +
`DBObj::GetDIDByEnum(..., 5)` — reuse OP8's ported GetDIDByEnum
machinery (category 4 precedent) for enum-table 5; slice starts by
dumping that table from installed DATs to pin the concrete DataID.
Child ids: listbox `0x1000039d`, create `0x100003a0` (present,
disabled — Create is a future campaign), enter `0x100003a2`, delete
`0x1000039f`, restore `0x1000039e`.
2. **Dialogs:** delete-confirm, please-wait, entering-world, error — the
retail dialog machinery from the OP8 WaitDialog work (`2a81e813`
mapped WaitDialog class type 0x19) is the base.
3. **Open item resolved here:** whether retail draws a render-loop
background scene behind the UI (pseudo-C proves only that the UI class
owns no viewport) — settle via user recollection + the visual gate
before polishing.
**Acceptance:** authored screen builds from DAT assets; button-state
matrix matches `UpdateButtons` oracle (incl. Delete↔Restore swap);
enter/delete/restore/error flows drive LA7 state end-to-end; suites
green. User visual gate at LA11 (screen look, dialog flows, delete +
restore against local ACE).
## LA9 — installer (first-run)
- DAT locate: auto-detect `%USERPROFILE%\Documents\Asheron's Call` and
`C:\Turbine\Asheron's Call` + manual picker; validate the four DATs.
- Bake: spawn `acdream-bake --dat-dir <dats> --out <DataDirectory>/pak/acdream.pak
--threads N`. Recon: default `--out` is INSIDE the DAT dir — the launcher
always passes `--out` explicitly. Progress: add `--progress-json` to
`AcDream.Bake` (JSONL progress lines alongside the existing 5-second human
text, which stays default) — scraping human text is fragile and we own the
tool. Recon: the bake has NO whole-file SHA — after a successful bake the
LAUNCHER computes and records SHA-256 + size + `BakeToolVersion` in its
install record, and re-verifies on subsequent startups (fast corruption
check trades a few seconds of hashing for never launching against a
half-written pak).
- Install record feeds LA3's session-config composition
(DatDirectory/PreparedAssetPath).
**Acceptance:** wizard flow tests over Launcher.Core (fake bake child emitting
`--progress-json` lines); bake-tool progress flag tests in
`tests/AcDream.Bake.Tests`; SHA record/verify tests; suites green. Connected
gate (user): clean-profile first-run against real DATs.
## LA10 — updater
- Manifest: GitHub Releases; `manifest.json` release asset — version, per-RID
client zip URL + SHA-256 + size, minimum-launcher version. Launcher pins
owner/repo in its config.
- Client update: poll on launch (+ manual check), download to staging, SHA
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
pointer swap, refuse while any session runs, keep previous version for
one-step rollback.
- Launcher self-update: staged download + target-local atomic replacement on
next start.
- Session-config composition targets `app/current`'s binaries.
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
fixture; rollback test; refusal-while-running test; self-update staging test;
suites green. Connected gate (user): staged-manifest update swap end-to-end.
### Pinned updater contracts (v1, BINDING)
This section is the single source of truth for every LA10 feed and on-disk
shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject
unknown or duplicate properties, and reject unsupported schema versions
before doing network, extraction, or activation work.
The production feed is pinned to GitHub owner/repository
`eriknihlen/acdream`; the launcher reads
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
Tests use a separate internal fixture constructor that may admit loopback HTTP;
that allowance never propagates to the production feed. Production manifest
and artifact URIs use HTTPS. Automatic redirects are disabled and every
redirect hop is validated before it is requested; redirect loops, a chain over
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"minimumLauncherVersion": "1.1.0",
"clients": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
},
"launchers": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
}
}
```
`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build
metadata is ignored for precedence; numeric identifiers are compared without
fixed-width integer overflow. RID keys are exact lowercase portable RIDs.
Both dictionaries are required and the running RID must have a client and a
launcher row. Artifact sizes are positive and capped by the launcher's
download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute.
Client ZIPs have the two host executables at their root
(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have
`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists.
Every extracted client version has
`DataDirectory/app/<version>/install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
Paths use `/`, are relative, normalized, unique under ordinal-ignore-case,
and sorted ordinally. `unixMode` contains only the portable permission bits
captured from the ZIP entry. Startup verifies every recorded regular file by
size/SHA, rejects unrecorded files/reparse points, and requires the two host
executables before admitting a version. Extraction uses a random sibling
directory under `DataDirectory/app/`; promotion to `<version>/` is one
same-volume directory rename.
`DataDirectory/app/current.json` is the only activation authority:
```json
{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" }
```
`previousVersion` is omitted for the first activation. Pointer writes are
write-through temporary-file + same-directory atomic rename. The last valid
pointer is also atomically preserved as `current.previous.json`; startup may
restore that exact backup only when `current.json` is missing/malformed and
the referenced version verifies. Orphan LA10 staging directories, download
archives, corrupt-version quarantine directories, and pointer temporaries are
transaction-owned by exact lowercase GUID names and are removed only under the
exclusive update lease; near-matching user names are preserved. A corrupt
installed version is never silently selected; the explicit one-step rollback
swaps the two verified pointer versions.
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
supervised launcher activity holds a shared OS handle from before executable
resolution until terminal process observation; launcher disposal requests
child termination and does not release that handle until the child is actually
observed terminal. An update/rollback holds the
exclusive handle for its entire recovery/download/extract/promote/pointer
transaction. Failure to acquire the exclusive handle is an immediate refusal,
not a wait behind a running session. The open handle, not lock-file contents,
owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
durable authority is `DataDirectory/launcher-update/pending.json` (schema 3):
```json
{
"schemaVersion": 3,
"transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged",
"version": "1.2.3",
"rid": "win-x64",
"targetDirectory": "<absolute current launcher directory>",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
],
"apply": null
}
```
Before mutation the verified staged launcher becomes the next-start helper and
waits for the initiating launcher PID without invoking a shell. It first copies
the complete verified payload into the target-local
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
owned metadata path, and obsolete paths from the previous ownership record:
```json
[
{
"path": "acdream-launcher.exe",
"operation": "install",
"hadOriginal": true,
"priorSha256": "<64 hex characters>",
"priorSize": 123,
"priorUnixMode": 0,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
},
{
"path": "new-support.dat",
"operation": "install",
"hadOriginal": false,
"priorSha256": null,
"priorSize": null,
"priorUnixMode": null,
"replacementSha256": "<64 hex characters>",
"replacementSize": 456,
"replacementUnixMode": 0
}
]
```
Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and
Linux mode bits; a no-original entry has all three prior fields null. Every
install entry likewise persists the verified replacement metadata, while a
remove entry has all three replacement fields null. The journal is invalid
unless those fields agree with `hadOriginal` and `operation`.
Existing targets are replaced with one same-filesystem atomic replace whose
backup is also target-local. Previously absent noncanonical files use one
same-filesystem rename; obsolete owned files use one rename into backup. The
canonical launcher path therefore contains either the complete old file or the
complete new file at every durable crash boundary. Rollback first performs a
zero-mutation preflight of the complete target-local transaction and every
journal entry. It rejects reparse points, unsafe parents, unrecorded paths,
ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior,
incoming, or discard file. Only a fully preflighted rollback may atomically
restore backups; newly created files move to target-local discard rather than
being deleted. The complete prior target set is then reverified before the
plan enters durable `rolledBack` state while retaining the journal. Retry is
allowed only after that prior set is reverified again and the plan returns to
`staged`. Thus rollback is atomic per file and idempotent after a process/power
loss. Any ambiguity preserves the applying plan and transaction evidence and
forbids launching the canonical path for manual recovery. Linux mode bits come
from the verified incoming file. A helper that cannot immediately
acquire the exclusive update lease defers the staged plan and exits without
restarting the old launcher, preventing restart loops.
Successful application writes strict target ownership metadata at
`<launcher directory>/launcher.install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
The archive may not supply that reserved metadata path. A prior valid record is
the only authority for obsolete-file removal; the first managed update does
not infer ownership of unrelated legacy files. On success the plan becomes
`awaitingConfirmation`; the new launcher confirms at its first managed
instruction, after which the helper releases its lease and the confirmed
launcher reclaims plan, data-transaction, and target-local residue. An
`applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target).
The helper restarts the restored canonical launcher only after a fresh complete
verification of the retained `rolledBack` journal; rollback corruption or an
unsafe backup/discard tree exits without starting either launcher.
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
exclusive lease without waiting and skips update cleanup entirely when another
session/staging transaction owns it. All plan paths are re-derived/contained
under pinned roots; the target directory must equal the actual launcher base
directory.
Every portable archive and persisted relative path rejects Windows device
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
## LA11 — closeout
- One exact operator script
`docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the
connection-free `tools/run-campaign-la-preflight.ps1` and followed by
serial user rows,
covering: all three launch modes vs local ACE, probe round-trip ×2 (no
lingering session), char-select visual matrix + delete flow, login-commands
+ plugin behavior on both hosts, add-server/add-account purely in UI,
clean-profile first-run wizard, staged update swap.
- Roadmap shipped-table entry, CLAUDE.md Current-state flip, memory distill,
ledger below completed, program closeout section.
## Review protocol
Per slice: implementer commit(s) → Opus dual-lens review (architectural +
retail-where-applicable) → fix round → narrow re-review of fixes → slice DONE
in ledger. Reviews name blast radius explicitly
(`claude-memory/feedback_blast_radius_single_lens.md`). Slices LA7/LA8 add the
retail-fidelity lens against named-retail symbols cited in the slice body;
LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
## Gate round 1 — 2026-08-15 (first live launch by the user)
The user's first hands-on launch found the launcher exiting on every click.
Root cause (`d54b8a78`): `MainWindow`'s constructor called
`AvaloniaXamlLoader.Load(this)` instead of the generated
`InitializeComponent()`, so every `x:Name` backing field was null and any
modal open/close threw out of the dispatcher into `Program`'s exit-74
guard. It reached the gate because NO test constructed `MainWindow`
filed and closed as **#399** (`2b439cc1`, merged): `Avalonia.Headless.XUnit`
view tests with falsification evidence (12/12 fail against the old code,
12/12 pass against the fix; launcher suite 66/66 Windows + native Ubuntu;
xunit→xunit.v3 in that test project). Same round (`e1e94697`): **#398**
closed — fatal exceptions now write a full-stack crash report under the
data root (isolated-roots-safe; the first cut leaked to the real data root
when parsing failed, caught live and fixed) — and `acdream-bake.exe` is now
co-deployed on plain Build, not just Publish, so a developer-built launcher
can actually run its first-run wizard (79.6 MB single file beside the
launcher, incremental, `--help` verified). One transient 65/66 on the first
post-merge test run did not reproduce across a clean rebuild + six repeats —
consistent with stale-artifact mixing, but if it EVER recurs, capture the
failing test name before anything else. Merged slice worktrees/branches
(la2/la3/la7a/la-uitest) removed. Opus batch review: PASS (HIGH
confidence) with 6 findings, all landed same-day: F1 the crash reporter's
by-construction claim was FALSE (the launcher holds passwords in three
fields; the true invariant — no throw site interpolates a credential
value — is now pinned by a forced-failure test), F2 the co-deploy's
Inputs covered only Bake's own sources, not its Content/Platform/Core/
Plugin.Abstractions closure (the stale-artifact class again; fixing it
exposed and fixed two more incrementality traps: SkipUnchangedFiles
leaving outputs older than inputs, and %(Item.Metadata) in a plain
Include not batching — a literal '%(...)' input is permanently
out-of-date), F3 dual bake publish on RID publishes (guarded by
_IsPublishing; verified 0 build-target co-deploys during a real publish),
F4 misattributed comment, F5 template-scoped x:Name false-fail (sweep now
walks the XML with template-ancestor tolerance), F6 dead using, plus the
optional Path.IsPathFullyQualified hardening on the crash reporter's
--data-dir fallback. Launcher 67/67, Launcher.Core 317/317. The §AI
connected script remains the open user gate.
## Gate round 2 — 2026-08-15 (first live launcher→client flow) — char-select matrix USER-PASSED
**USER-PASSED 2026-08-15 (end of round):** the character-select visual/
interaction matrix — stretched-canvas look with bilinear filtering,
aligned widgets, left-justified roster, World box reading the live server
name ("sawato"), and the centered exit confirmation — all accepted on the
live launcher→client flow. Round-2 commits after the round-1 batch:
`6e1c0967` (session-config launches force the retail UI), `9ce72925`
(PFID_CUSTOM_RAW_JPEG decode + resolution guards), `73041d70`
(whole-canvas AD-98 scale + inverse input), `308f40a3` (linear-twin
bilinear stretch), `ef96c554` (exit confirmation + authored justify +
world name, AD-99), `2e6d69dd` (#400), `0a7dc7d6` (durable world-name
read + canvas-centered dialogs). Remaining before shipment: the formal
§AI script rows (probe ×2, headless+plugins+login commands, delete/
restore, A→B update swap, row I Linux), and the final-HEAD preflight
re-run.
**Round REVIEW-CLOSED 2026-08-15:** the owed Opus dual-lens batch review
of the six round-2 commits returned PASS with 8 findings; the fix round
(`0baebce2` — headline: `RetailWaitDialogView` was the ONE dialog view the
EffectiveCanvasSize sweep missed, firing on ENTER; plus the two stale
deleted-mechanism doc assertions, the Confirmation `0xAC` property,
truncating input mapping, the IsCurrent world-name gate, the AD-98
evidence note) closed all seven in the narrow re-review; F2 filed as
#401 (invert RetailUi to opt-out). The review also proved the
`DatWidgetFactory` justify widening has ZERO regressions across all 35
layout fixtures (303 buttons swept; the 16 authored-Left all already
left-aligned via their face-child branch) and is a move TOWARD retail
(`CalcJustification @0x00467260` has no lifted-from-child condition).
Two real defects, both root-caused and fixed:
1. **`6e1c0967` — launcher-spawned clients had NO interface at all.**
`RetailUi` rode the dev env var `ACDREAM_RETAIL_UI`; `FromSessionConfig`
inherited the env parse; the launcher strips `ACDREAM_*` from children
(LA11 isolation). Product launches therefore got the dev default: world
rendering, zero UI — character screen included. Session-config launches
now force `RetailUi = true` (a session-config launch IS a product
launch); the env flag remains the dev-launch opt-in. Test-pinned with a
null env.
2. **`9ce72925` — character-select screen rendered magenta background/
fills.** The screen's 800×600 root background (`0x06007576`) is
`PFID_CUSTOM_RAW_JPEG` — a complete JFIF stream retail hands to the
Intel JPEG Library (`RenderSurface::CreateFromSourceData @0x004440a0`),
with Width/Height legitimately 0 on disk. `SurfaceDecoder` had no JPEG
case AND a non-positive-dimension guard, so it fell silently to the
magenta placeholder; the listbox/ENTER fills are transparent, so one
broken background bled through as three symptoms. Fixed via
StbImageSharp (managed, Linux-safe; codec-library substitution per the
BCnEncoder precedent — no register row). BOTH silent traps now log once
per id (id-resolves-but-undecodable in `SurfaceDecoder`;
id-missing-from-DATs in `TextureCache`) — the existing magenta guard
only covered id-0. New installed-DAT sweep asserts every char-select
media id decodes non-magenta. Full suite 14,034 green.
Session-orchestration facts this round: the machine gained PowerShell 7
(winget, user-approved — the LA fixture tooling hard-requires it); an
orphan feed server from the earlier session held port 43119 with stale
fixture data (stopped); the launcher self-update bootstrap restart on a
dev binary is EXPECTED (staged launcher update → exit → respawn).
Observations still open for this round: the duplicated "versioned client
is unavailable" status line (cosmetic), and verifying Create Character is
disabled on the live screen.
## Ledger
| Slice | Status | Commits | Review | Notes |
|---|---|---|---|---|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. |
| LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. |
| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. |
| LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. |
| LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. |
| LA6 | **DONE + MERGED 2026-08-14** | `41b15efd`, `259f0e5a`, merge `2bb8ccb6` | Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS | Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922. |
| LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. |
| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. |
| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. |
| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0LA10 gate: 13,972+5 skip. |
| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact AI operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at `a22f5411` passed 32/32 with 14,012 tests + 5 skips and report SHA-256 `49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df`. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. |

File diff suppressed because one or more lines are too long

View file

@ -1,691 +0,0 @@
# Release stabilization and human-maintainability campaign
**Status:** PROPOSED — plan recorded; implementation not started
**Created:** 2026-08-18
**Audit baseline:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e`
**Evidence:** [`../reviews/2026-08-17-release-maintainability-audit.md`](../reviews/2026-08-17-release-maintainability-audit.md)
**Findings:** [`../reviews/findings-ledger.md`](../reviews/findings-ledger.md)
**Coverage proof:** [`../reviews/coverage-ledger.md`](../reviews/coverage-ledger.md)
## 1. Goal
Prepare acdream for a responsible public release and for maintenance by human
developers who do not have access to prior AI conversations, private worktrees,
or campaign memory.
The campaign succeeds when a new maintainer can clone the repository, identify
the current architecture and supported release, reproduce the build and test
gate, understand why non-obvious retail behavior exists, and publish or roll
back an authenticated release using repository-owned instructions.
This is a stabilization program, not a rewrite. The existing Runtime/App/
Headless architecture remains the foundation unless a slice proves a specific
boundary is wrong.
## 2. Binding principles
1. **Protect behavior before cleanup.** Establish a deterministic complete gate
before broad refactors, comment cleanup, or file decomposition.
2. **Distill knowledge; do not erase it.** No note, comment, issue history,
diagnostic, capture, or raw artifact is removed until its durable value has
a verified destination.
3. **One current truth.** Stable architecture and release state must not depend
on choosing between README, roadmap, milestone, campaign, memory, or
tool-specific instruction copies.
4. **Separate evidence from contracts.** Source comments explain the current
invariant. Dated research records preserve investigation history. Raw
captures live in an explicit artifact tier.
5. **No count-only gates.** A test total is meaningful only when the report says
which hermetic, installed-DAT, live, visual, manual, and diagnostic lanes
actually ran.
6. **Bound every external interaction.** Process waits, network operations,
test runs, and release steps require timeouts, cancellation, and diagnostic
artifacts on failure.
7. **Small reversible slices.** Each slice gets focused tests, a complete gate,
a reviewable commit, a rollback description, and a plan-ledger update.
8. **No opportunistic feature work.** New gameplay features wait unless needed
to prove or repair a release blocker.
## 3. Knowledge-preservation protocol
Every cleanup candidate is classified before it moves:
| Class | Durable value | Destination |
|---|---|---|
| Current invariant | Required behavior, ordering, ownership, threading, or retail rule | Short source comment and/or maintained architecture contract |
| Decision rationale | Alternatives considered, failed attempts, tradeoffs, gate outcome | Dated decision/research record linked from the current contract |
| Reproducible evidence | Minimal fixture, retail symbol/address, script, checksum, expected result | Versioned fixture/research record in Git |
| Raw evidence | Large logs, captures, Ghidra state, screenshots, dumps | Approved versioned artifact store with manifest, hash, provenance, and retention policy |
| Superseded or incorrect claim | Historically useful but no longer operative | Marked `SUPERSEDED` with successor link; archive after references are migrated |
| Duplication/noise | Repeats a preserved fact and adds no independent evidence | Delete only after destination/link validation |
Before deleting or rewriting historical material, all of these must be true:
- its current invariant is recorded at the owning code or architecture seam;
- useful retail provenance, failed approaches, and acceptance evidence remain
searchable under stable identifiers;
- inbound links and source comments point at the surviving destination;
- any raw artifact has an approved distribution, privacy, and licensing status;
- the replacement was reviewed by someone other than its author;
- the complete gate passes after the move.
Git history alone is not the preservation mechanism. History may later be
rewritten to remove large or legally restricted artifacts.
## 4. Campaign dependency map
```text
R0 baseline/authority
-> R1 launcher deadlock
-> R2 reproducible complete gate
-> R3 truthful test lanes
-> R5 documentation authority
-> R6 dead surfaces and tools
-> R7 plugin/config hardening
-> R8 bounded decomposition
-> R10 release candidate
R4 licence/provenance/release governance ---------------------> R10
R9 artifact migration (depends on R4 decisions) --------------> R10
```
R4 starts in parallel because it requires owner/legal decisions. It blocks a
public release but does not block the technical safety work in R1R3.
## 5. Slice map
| Slice | Outcome | Depends on | Release blocking |
|---|---|---|---|
| R0 | Baseline and durable campaign authority accepted | — | yes |
| R1 | Launcher shutdown lock inversion fixed and deterministic | R0 | yes |
| R2 | Pinned clean build and complete bounded CI gate | R1 | yes |
| R3 | Test results truthfully distinguish executed, skipped, and diagnostic work | R2 | yes |
| R4 | Licence, provenance, credential disclosure, and release ownership decided | R0; parallel | yes |
| R5 | One current documentation authority; public docs match the product | R2R3 | yes |
| R6 | Dead presentation/backend/probe surfaces removed; supported tools reproducible | R3R5 | normally yes |
| R7 | Plugin compatibility/lifetime and diagnostic configuration hardened | R3 | yes for advertised plugin release |
| R8 | Highest-risk giant owners decomposed only at proven seams | R3, R6R7 | selective |
| R9 | Large/generated research artifacts moved under an approved policy | R4 | yes if repository is distributed |
| R10 | Clean-clone release candidate and rollback rehearsal | all blocking slices | yes |
## 6. R0 — Baseline and authority
### Work
- Review and accept this plan and the six documents under `docs/reviews/`.
- Record the exact starting commit, SDK, package sources, operating systems,
supported release platforms, and current external prerequisites.
- Decide who owns legal/provenance decisions, CI/release credentials, and final
release approval.
- Pause unrelated feature campaigns until R1R3 establish the safety net.
- Preserve the audit baseline before any cleanup; do not rewrite artifact
history in this slice.
### Exit criteria
- Plan and audit artifacts are tracked in the repository.
- One named owner exists for technical release approval and one for
licensing/provenance approval; the same person may hold both roles.
- The ledger in §17 identifies R1 as the only active implementation slice.
## 7. R1 — Fix the launcher shutdown deadlock first
### Confirmed failure
`LauncherProcessSupervisor.Dispose` currently holds the supervisor `_gate`
while disposing a child. `WindowsSystemChildProcess.Dispose` enters
`System.Diagnostics.Process.Dispose`; concurrently, the process-exit callback
can enter `OnProcessExited` and try to publish state through the same supervisor
gate. The captured wait cycle is:
```text
shutdown: supervisor _gate -> Process internals
exit callback: Process internals -> supervisor _gate
```
The complete solution test process hangs on
`ANullStderrLogPathBehavesExactlyAsBeforeForBothChildProcessKinds`; the
Launcher.Core project can pass alone because the race timing changes.
### Design constraints
- Never call child/process operations that may wait, dispose, raise callbacks,
or execute external code while holding `_gate`.
- Under `_gate`, make only the minimal state transition and snapshot the exact
children/work to retire.
- Perform unsubscribe, stop, kill, wait, and dispose work outside `_gate`.
- Exit callback and explicit disposal must converge idempotently regardless of
which arrives first.
- Preserve exact terminal-state ordering, status publication, graceful-stop
behavior, and child ownership; do not solve the hang by dropping callbacks.
- A failed cleanup must remain observable without starving later cleanup.
### Required tests
- Add a barrier-controlled race test that deterministically pauses the child
exit callback while disposal begins. Do not use `Thread.Sleep` as the oracle.
- Cover exit-before-dispose, dispose-before-exit, simultaneous exit/dispose,
repeated dispose, stop timeout/kill fallback, and callback failure.
- Assert one terminal publication, no resurrection, no orphan child, no held
supervisor lock during process disposal, and bounded completion.
- Run the focused race test repeatedly after its deterministic single pass.
- Run all Launcher.Core tests.
- Run the complete Release solution twice in fresh processes under a documented
timeout and retain hang dumps if either run fails to terminate.
### Exit criteria
- No process/child disposal occurs under the supervisor gate.
- The deterministic race test fails against the baseline mechanism and passes
against the fix.
- Two complete bounded solution runs finish with zero failures.
- F-009 and T-001 receive exact fix commit and gate evidence.
## 8. R2 — Reproducible build and complete CI gate
### Work
- Pin the accepted .NET 10 SDK feature band in `global.json`.
- Centralize common compiler/analyzer settings and package versions; enable
locked restore for release/CI.
- Eliminate all 26 clean-rebuild test warnings or make a narrowly justified,
centrally documented exception fail-safe.
- Build every supported product and every supported tool from a clean checkout.
- Make CI run the complete solution, not only portability subsets. Give each
project/process a timeout and collect test logs plus managed dumps on hangs.
- Preserve focused Windows/Linux portability lanes, but do not represent them
as the complete gate.
- Record restore sources, SDK/runtime, RID, commit, executed/skipped counts, and
artifact hashes in every release report.
### Exit criteria
- A clean clone restores and builds with the pinned toolchain and no warnings.
- Complete CI runs every default release test project and fails on timeout.
- The launcher hang cannot silently consume the CI job indefinitely.
- Package resolution and the gate command are reproducible from repository
instructions alone.
## 9. R3 — Make test reporting truthful
### Required lanes
1. **Hermetic release lane:** default CI; unavailable local data is never a
silent passing return.
2. **Installed-DAT/prepared-package lane:** explicit prerequisites and per-suite
skip identity; result published separately.
3. **Live/connected/visual/listening lane:** operator-owned, dated evidence;
never counted as ordinary unit coverage.
4. **Diagnostic/manual lane:** probes, dumps, fixture generators, and
characterization programs invoked explicitly outside default test totals.
### Work
- Replace the 271 asset/environment empty-return tests with explicit lane
requirements, truthful skips, or hermetic fixtures.
- Move or give stable assertions to the 51 output-only diagnostic methods.
- Delete/replace the three confirmed useless entire cases and the tautological
assertions catalogued in the audit.
- Remove the duplicate theory row and make the clean rebuild warning-free.
- Remove or re-home at least 52 tests with the unreachable panel stack.
- Retire temporary source-shape freezes once a semantic architecture/behavior
guard exists; retain whole-tree dependency guards that express real rules.
- Assign and stabilize the seven documented load-sensitive tests. Do not hide
them with generic retries.
- Inject controllable time into double-click and real-time transport tests.
### Exit criteria
- Default test success means every discovered default contract executed.
- Reports give exact reasons and prerequisite identity for every skip.
- Diagnostics and manual generators do not inflate release regression totals.
- No known duplicate row, literal tautology, or permanent empty scaffold remains
in the default suite.
## 10. R4 — Licence, provenance, security, and release ownership
This slice requires explicit project-owner decisions and, where appropriate,
qualified legal review. The implementation agent records evidence but does not
invent a redistribution basis.
### Work
- Select the project licence and establish contributor/code ownership.
- Audit WorldBuilder-derived code, dependency notices, named-retail/decompiler
exports, PDB-derived data, Ghidra databases, captures, images, and DAT-derived
fixtures for provenance and redistribution status.
- Decide which research artifacts may be public, private, regenerated, or
deleted from distributable history.
- Add SECURITY, CONTRIBUTING, changelog/version authority, disclosure and
deletion behavior for plaintext launcher credentials, and a vulnerability
response path.
- Define release artifact contents, supported platforms, SBOM/provenance,
signing/attestation policy, checksums, update manifest generation, rollback,
and release approval.
### Exit criteria
- Publicly distributed source and artifacts have an approved licence and
complete notices/provenance inventory.
- Users are told exactly how credentials are stored and removed.
- The launcher updater's production manifest and archives are generated and
verified by a repository-owned release process.
## 11. R5 — One current documentation authority
### Work
- Correct public README claims to the actual Vulkan-only client and retained UI.
- Make `docs/README.md` stable navigation plus a generated current-status block
sourced from one structured milestone/release ledger.
- Keep architecture documents limited to durable boundaries, ownership,
threading/lifetime, and data flow. Move commit/test-count/rollback chronology
to dated closeout records.
- Replace duplicated `AGENTS.md`/`CLAUDE.md` product truth with one maintained
tool-neutral source and generated thin adapters; fail CI on drift.
- Mark every memory/plan/spec as current, active, superseded, or historical.
- Normalize active issue/divergence indexes and validate their IDs, statuses,
paths, and links mechanically.
- Apply the knowledge-preservation protocol before shortening any campaign or
issue record.
### Exit criteria
- A new maintainer receives the same current answer from README, documentation
map, architecture, milestone/status ledger, and agent instructions.
- No current authority links missing private `claude-memory`, absent skills, or
developer-local paths.
- Historical records remain searchable but cannot override current truth.
## 12. R6 — Dead surfaces, diagnostics, and tools
### Work
- Decide and then remove or explicitly support the unreachable
`IPanelRenderer`/old panel stack and its tests.
- Remove stale OpenGL/framebuffer/ImGui apparatus and failed temporary-cleanup
markers from shipping assemblies after preserving useful evidence.
- Stop including the smoke plugin in release output by default.
- Classify every tool/script as supported, diagnostic, research-only, or
archived. Repair the five broken C# tools chosen as supported; remove
developer-home/old-worktree paths and document exact prerequisites.
- Centralize environment/diagnostic configuration at composition roots.
### Exit criteria
- Shipping assemblies and package contents contain no abandoned presentation
backend or sample plugin by accident.
- Every supported tool builds from the pinned clean checkout.
- Research-only tools are clearly invoked outside the release build.
## 13. R7 — Plugin and configuration contracts
### Work
- Enforce supported plugin API versions before loading code.
- Implement manifest dependencies or remove the unsupported promise.
- Publish/version `AcDream.Plugin.Abstractions` if plugins are advertised.
- Report registration cleanup and callback failures without preventing
best-effort teardown; prove collectible load-context release under failures.
- Replace absent/null allow-list ambiguity with one explicit production default.
- Replace direct hot-path environment reads/process-static mutable diagnostics
with immutable session-scoped configuration and typed sinks.
### Exit criteria
- An incompatible plugin fails before activation with an actionable message.
- Disable/dispose reports all cleanup failures and cannot silently retain host
registrations.
- Graphical and headless hosts have the same documented plugin/config default.
## 14. R8 — Bounded structural decomposition
This slice begins only after R1R3. File size alone does not authorize a split.
### Priority candidates
- `RuntimeSetPositionState`
- `TransitionTypes`
- `RetailUiRuntime`
- `LiveEntityRuntime`
- `WorldSession`
- `WbDrawDispatcher`
### Rules
- Identify one ownership/lifetime or pure-algorithm seam at a time.
- Preserve a single state owner; do not replace a large class with mirrored
mutable state or a service graph of aliases.
- Prefer partial-file navigation when a state machine must remain one owner.
- Establish behavior/sabotage tests before extraction and remove corresponding
temporary source-text freezes afterward.
- Replace Chorizite/GL vocabulary in prepared-content DTOs with versioned
acdream-owned semantics at a separately reviewed boundary.
### Exit criteria
- Each extraction reduces change coupling or improves ownership clarity; line
count reduction alone is not success.
- Runtime behavior, retail evidence, allocations, and teardown ledgers remain
equivalent under the relevant focused and complete gates.
## 15. R9 — Repository artifact migration
### Work
- Inventory the approximately 575 MiB of Ghidra state, 299 MiB research tree,
and 151 MiB tracked logs by provenance, sensitivity, reproducibility, and
ongoing value.
- Keep compact fixtures, scripts, tool versions, summaries, and checksums in
Git. Move approved raw bundles to a versioned artifact store.
- Add a manifest/bootstrap command that verifies artifact identity.
- Define retention, redaction, access, and backup policy.
- Treat Git-history rewriting as a separately approved migration with backup,
contributor coordination, remote replacement, and verification. Never do it
as an incidental cleanup command.
### Exit criteria
- A normal clone contains what build/test/maintenance requires without opaque
generated databases or raw logs.
- Authorized researchers can retrieve exact approved evidence by manifest and
hash.
- Restricted or non-redistributable material is absent from public history.
## 16. R10 — Release-candidate gate
From a fresh clone on every supported release platform:
- restore with the pinned, locked toolchain;
- build every shipped product and supported tool with zero warnings;
- run the complete hermetic test lane with no failures, silent no-ops, hangs,
or generic retries;
- run and report the applicable DAT, connected, visual/listening, updater,
installer, graceful-shutdown, and rollback gates;
- generate versioned per-RID packages, plugin abstraction package if supported,
SBOM/provenance/checksums, and updater manifest;
- install/update/rollback using only public release instructions;
- verify package contents contain no credentials, developer paths, smoke
plugin, raw probes, or unapproved research artifacts;
- obtain technical and licensing/provenance approval.
Public release remains **NO-GO** until every blocking slice is closed.
## 17. Cross-session execution ledger
This table is the resume authority. Update it in the same commit as every slice
checkpoint. Do not infer status from chat history.
| Slice | Status | Commit(s) | Evidence/gates | Exact next action |
|---|---|---|---|---|
| R0 | PLAN ACCEPTED; R4 OWNER DECISION DEFERRED | — | Audit complete at `15539a22`; user completed R1R3 | Retain the plan and audit artifacts as campaign authority |
| R1 | COMPLETE; MERGE AUTHORIZED 2026-08-18 | `0a934cf5` | 2026-08-18 checkpoint below; F-009/T-001 resolved and committed | Merge with the complete R1R3 stabilization branch |
| R2 | COMPLETE; MERGE AUTHORIZED 2026-08-18 | `2ac05486`, `c38f6b88` | Checkpoints below; F-014/F-019 resolved, F-010 machine-readable, supported .NET tool portion of F-004 resolved | Merge with the complete R1R3 stabilization branch |
| R3 | COMPLETE; MERGE AUTHORIZED 2026-08-18 | `14d371a0`, `b64c8041` | Final inventory: 1,254 files, 11,414 attributed methods, 22 approved source readers; Release gate: 14,346/14,346 | Merge the stabilization branch; retain the closeout ledger as authority |
| R4 | DEFERRED BY USER FOR FRIEND-ONLY RELEASE | — | F-001/F-026/F-031/F-034 remain public-release blockers | Resume before any public release; keep credentials and research artifacts out of friend packages |
| R5 | DEFERRED BY USER | — | F-002/F-003/F-006/F-007/F-011/F-020/F-027/F-029/F-032 | Resume later with the bounded documentation-authority goal |
| R6 | NOT STARTED | — | F-004/F-005/F-012/F-016/F-023/F-028 | Wait for R3/R5 |
| R7 | NOT STARTED | — | F-017/F-018/F-024/F-025 | Wait for R3 |
| R8 | NOT STARTED | — | F-008/F-013/F-033 | Wait for R1R3 and cleanup decisions |
| R9 | NOT STARTED | — | F-034 plus R4 provenance decisions | Wait for R4 |
| R10 | NOT STARTED | — | All blocking findings | Wait for blocking slices |
### R1 implementation checkpoint — 2026-08-18
**Working-tree base:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e`
**Commit:** `0a934cf5` on `codex/release-stabilization`; the checkpoint is
durable on that campaign branch but is not yet merged to `main`.
Implementation:
- `LauncherProcessSupervisor.Dispose` now transfers `_process` ownership to a
local and clears the field under `_gate`, then performs stop, event removal,
and child disposal outside the gate.
- Public `Stop` and disposal share one `StopProcess` implementation, preserving
graceful-stop, close-window, timeout, kill, and post-kill observation order.
- `OnProcessExited` reads the event sender's optional exit code without holding
`_gate`, then commits the terminal transition through the existing ordered
state publisher. A callback already captured during teardown may therefore
finish instead of forming the supervisor/Process lock cycle.
- `DisposeAllowsAnAlreadyCapturedExitCallbackToComplete` uses explicit barriers:
the fake captures the exit delegate before unsubscription; its disposal
releases the callback and waits for it to return. There are no timing sleeps
in the oracle, and an emergency release keeps failure against the old code
bounded rather than wedging the test host.
Sabotage and focused evidence:
- With the old lock shape temporarily restored and the new test retained, the
test failed with its expected five-second timeout. The fake's cleanup barrier
then released the old cycle, so the test process exited normally.
- With the fix restored, the same test passed in 21 ms.
- The race test passed 25/25 times in fresh `dotnet test` processes.
- All `LauncherProcessSupervisorTests` passed: 22/22.
- Complete Launcher.Core passed under a 180-second hard process bound:
339 passed / 0 skipped / 0 failed in 52 seconds.
Complete-solution evidence:
- Release build completed inside a 300-second bound with 0 warnings / 0 errors
in the evaluated incremental build.
- The exact serialized command was
`dotnet test AcDream.slnx -c Release --no-build --no-restore --nologo -m:1`,
launched in a fresh child process for each run. An outer process watchdog
allowed 900 seconds, killed the complete process tree on expiry, and treated
timeout as failure.
- Run 1: 12 assemblies, 14,748 passed / 77 skipped / 0 failed, 1:28.822,
bounded exit code 0.
- Run 2: 12 assemblies, 14,748 passed / 77 skipped / 0 failed, 1:30.241,
bounded exit code 0.
Adjacent evidence, deliberately not folded into R1:
- One default-parallel whole-solution run terminated normally in 58.148
seconds—important negative evidence for the former hang—but failed one
`AcDream.Launcher.Tests` Avalonia headless cleanup because a compositor was
accessed from a non-owning thread. The Launcher test project then passed
67/67 alone. R1 makes no Avalonia changes; R2/R3 must decide the supported CI
scheduling and ownership of that pre-existing parallel-run failure.
- The known duplicate Core theory row and 77 skip classifications remain
unchanged and belong to R3. R1 does not use their headline count as proof of
test quality.
Changed implementation/test files:
- `src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs`
- `tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs`
Rollback is `git revert 0a934cf5`; do not rewrite branch history.
### R2 complete-gate checkpoint — 2026-08-18
**Working-tree base:** `0a934cf5781c003375c14af9a1f565254df0f9f9`
**Commit:** `2ac05486` on `codex/release-stabilization`; the checkpoint is
durable on that campaign branch but is not yet merged to `main`.
Implemented gate:
- `global.json` pins the accepted .NET 10 SDK feature band at `10.0.300` with
`latestPatch` roll-forward and prerelease SDKs disabled. Every existing
`actions/setup-dotnet` step now reads that file instead of floating on
`10.0.x`.
- `tools/run-release-gate.ps1` discovers every project under `tests/` that
declares `Microsoft.NET.Test.Sdk` or `IsTestProject`, verifies the project is
present in `AcDream.slnx`, restores/builds the solution, and runs each test
assembly exactly once in its own Release process. It does not retry.
- Restore, build, and each test process have 600/900/600-second outer bounds.
Tests additionally use VSTest's 180-second per-test blame-hang collector with
mini dumps. An outer timeout kills the complete process tree and reports exit
code 124; GitHub Actions adds a 45-minute job bound.
- Every run writes exact commands and output, one TRX per assembly, any blame
sequence/dumps, `dotnet --info`, configured NuGet sources, commit/branch/RID,
aggregate executed/passed/skipped/failed counts, and `SHA256SUMS.txt`.
- `.github/workflows/release-gate.yml` runs the gate on pull requests, pushes
to `main`, and manual dispatch on `windows-latest`, then uploads the evidence
even when the gate fails. The focused Windows/Linux portability and Vulkan
lanes remain separate and are no longer the only deterministic CI coverage.
- `docs/release-gate.md` is the repository-owned local/CI runbook.
Test-isolation corrections, with no product behavior change:
- Four `MainWindowViewTests` that call `Show()` now close their window and pump
dispatcher cleanup in `finally` on the owning Avalonia test session. The old
tests leaked shown, thread-affine compositor state to runner teardown; no
suite serialization or retry was added.
- The complete gate's first evidence run correctly failed
`RealChildStderrIsCapturedForTheProcessStartInfoPath`: its live polling helper
briefly denied write sharing, so the final async stderr callback observed an
`IOException` and the deliberately no-throw capture sink latched off. The
helper now reads with `FileShare.ReadWrite | FileShare.Delete`, matching the
production status tailer; its assertions and five-second bound are unchanged.
Verification:
- Focused `MainWindowViewTests`: 13 passed / 0 skipped / 0 failed.
- Three fresh default-parallel whole-solution runs completed inside independent
180-second process bounds with 12/12 TRX files and no Avalonia cleanup error:
56.719, 56.321, and 58.506 seconds. Each reported 14,748 passed / 77 skipped /
0 failed.
- The stderr ProcessStartInfo test passed 25/25 fresh-process repetitions after
the live-reader correction.
- The actual outer-watchdog function killed a controlled fixture process tree
at 2.107 seconds, returned 124, and left no child process.
- The repository command `pwsh ./tools/run-release-gate.ps1` completed in
107.337 seconds on SDK `10.0.300`, RID `win-x64`: 12 assemblies, 14,748
executed and passed / 77 skipped / 0 failed. The evidence manifest contains
28 verified SHA-256 entries.
Deliberately still open in the wider R2 slice:
- the 26 warnings observed by a clean recompilation, centralized compiler and
package settings, package lock files/locked restore, and broken or unsupported
tool-project decisions;
- the known duplicate Core theory row and classification of the 77 skips, which
remain R3 work; and
- stale public headline counts, which must be corrected with the documentation
authority work rather than hand-edited as part of this gate checkpoint.
Therefore this checkpoint closes F-014 on the campaign branch and the SDK part
of F-019, and gives F-010 a truthful machine-readable count. It does not claim
the broader R2 reproducibility slice or R3 test-quality cleanup is complete.
Rollback is `git revert 2ac05486`; do not rewrite branch history.
### R2 reproducibility closeout — 2026-08-18
**Working-tree base:** `b459e0cf0cab9241b0771d2ce6838083f2c84162`
**Implementation commit:** `c38f6b88522750abc2e4acf1898ed566c5576e4a`
on `codex/release-stabilization`; the closeout is durable on that campaign
branch but is not yet merged to `main`.
Repository policy and dependency graph:
- `Directory.Build.props` is the common .NET 10, language, nullable, latest
analysis, warnings-as-errors, deterministic-build, and lock-file authority.
- `Directory.Packages.props` centrally pins all 30 direct package versions.
All 89 `PackageReference` sites are versionless; no project-local version can
silently drift.
- `NuGet.Config` clears machine fallback folders and package sources, then
declares only `nuget.org`.
- Every supported project owns `packages.neutral.lock.json` (44 files). Every
shippable source project additionally owns `packages.win-x64.lock.json` and
`packages.linux-x64.lock.json` (14 of each; 72 graphs total). Conventional
`packages.lock.json` files are intentionally absent because NuGet gives that
filename precedence over `NuGetLockFilePath`, preventing adjacent neutral
and RID graphs.
- `tools/update-package-locks.ps1` is the one intentional update path. The
release gate and the launcher's nested Bake publish use forced locked restore,
so stale `obj/` assets cannot hide a disagreement and a normal gate cannot
rewrite dependency resolution.
Complete maintained build surface:
- All 13 tracked .NET tools were repaired against package/owned interfaces,
documented in `tools/README.md`, and added to `AcDream.slnx`.
- The gate now verifies that every `.csproj` under `src/`, `tests/`, and
`tools/` is a solution member, requires the expected lock graphs, and records
their hashes in the evidence bundle. The supported graph is 44 projects.
- The older script/probe archive under F-004 is unchanged. Classifying that
historical material remains R6 work; the R2 change only makes the maintained
.NET tools truthful and reproducible.
Warning cleanup and test-gate stability:
- A clean complete recompilation originally exposed 26 warnings, all in test
and diagnostic code. Assertion-specific analyzers, nullable test doubles,
and nullable DAT probe boundaries were corrected without changing product
behavior.
- The one known redundant historical Core theory input remains for R3 behind a
site-scoped `xUnit1025` suppression. The central policy still makes any new
duplicate row a build failure.
- The launcher's seven editor-focus variants now execute in one Avalonia test
application session. This prevents the headless framework from attempting
compositor reinitialization on a non-owning thread. The suite passed 11
consecutive focused runs before the full gate. Aggregating seven theory rows
into one fact reduces the headline passed count by six; all seven variants
are still executed and asserted.
- The launcher package-boundary test now verifies versionless project
references against the central version table rather than incorrectly
requiring inline versions.
Reproducibility evidence:
- Forced locked re-evaluation of all 44 neutral and all 28 RID graphs changed
zero lock hashes.
- A locked restore into an empty global package cache, with `--no-cache`,
succeeded from the sole configured source. NuGet assets recorded no fallback
package folder.
- A forced nested launcher-to-Bake publish selected the appropriate RID graph,
emitted the Bake executable, and changed zero lock hashes.
- `pwsh ./tools/run-release-gate.ps1` ran on the clean exact commit
`c38f6b88522750abc2e4acf1898ed566c5576e4a`, SDK `10.0.300`, RID `win-x64`,
in 121.398 seconds. Restore was forced and locked; all 44 projects built with
0 warnings / 0 errors; all 12 test assemblies completed with 14,742 passed /
77 skipped / 0 failed. The gate recorded `WorktreeDirty: false`.
No product source behavior changed in this closeout. The known duplicate theory
row, classification of the 77 environment-dependent skips, test naming/value
review, and stale public headline counts remain explicitly assigned to R3 and
the later documentation-authority slice. R2 is complete on the campaign branch.
Rollback is `git revert c38f6b88`; do not rewrite branch history.
## 18. Session start protocol
Every implementation session begins by:
1. reading this plan, the executive audit, and the findings for the active
slice;
2. running `git status --short`, `git rev-parse HEAD`, and checking the ledger's
recorded commit against the working tree;
3. reading all files/tests named by the active finding before editing;
4. confirming there is no overlapping uncommitted user work;
5. restating the bounded slice outcome and gates in the session update;
6. working only the first non-blocked active slice unless the plan explicitly
allows parallel work.
## 19. Session handoff protocol
Before ending any session, record in §17 or a linked dated closeout:
- exact commit/worktree state and every file changed;
- decisions made and alternatives rejected;
- invariant/evidence destinations for anything removed;
- exact commands, pass/fail/skip counts, timeouts, and artifact paths;
- review findings and whether they were closed;
- remaining risks, blockers, and user/legal decisions;
- rollback command or precise reversal procedure;
- one exact next action that can be started without chat context.
A slice is not `DONE` because its code compiles or a focused test passes. It is
done only when its exit criteria, complete required gate, evidence update,
review, and cross-session ledger entry are all complete.
## 20. Immediate next action
Fast-forward `main` through the completed R1R3 stabilization branch and push
the merged result. R4 and R5 are explicitly deferred for the friend-only
release. Before any public release, resume R4; when maintainability work
resumes, begin with the bounded R5 documentation-authority goal. Do not begin
bulk comment, artifact, giant-file, or unrelated cleanup first.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,198 +0,0 @@
# Complete Release gate
The default release gate is repository-owned and uses the SDK feature band in
`global.json`:
```powershell
pwsh ./tools/run-release-gate.ps1
```
The command verifies that `AcDream.slnx` contains every `.csproj` under `src/`,
`tests/`, and `tools/`, performs a locked restore, builds that complete graph,
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
`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.
Build and dependency policy is repository-owned:
- `global.json` pins the accepted .NET SDK feature band;
- `Directory.Build.props` supplies the common target framework, language,
nullable, analyzer, warnings-as-errors, deterministic-build, and lock-file
settings;
- `Directory.Packages.props` is the only direct package-version table;
- `NuGet.Config` clears machine sources and permits only `nuget.org`; and
- each supported project commits its own `packages.neutral.lock.json`; shipped
source projects also commit `packages.win-x64.lock.json` and
`packages.linux-x64.lock.json` for RID-specific publishes.
The nonstandard neutral name is intentional. NuGet always prefers a
conventional `packages.lock.json` when one exists, even when
`NuGetLockFilePath` selects a RID-specific file. Do not introduce conventional
lock files beside these three repository-owned graphs.
The gate uses `dotnet restore --locked-mode --force-evaluate`. The forced
evaluation makes the result independent of stale `obj/` assets; locked mode
still prevents rewriting. If a project or central package version disagrees
with a committed lock file, restore fails instead of silently changing the
dependency graph. The launcher's nested Bake publish uses the matching
RID-specific lock and the same forced locked evaluation.
Each restore, build, and test process has an outer hard timeout. Every test
also runs with VSTest blame-hang enabled: after three minutes in one test, the
test host is terminated and a mini dump is collected; after ten minutes, the
outer watchdog kills the complete `dotnet test` process tree. CI additionally
has a 45-minute job bound.
Evidence is written to `artifacts/release-gate/`:
- `release-gate-summary.json` records the commit, branch, worktree state, SDK,
RID, bounds, process outcomes, assembly list, and
executed/passed/skipped/failed totals;
- `environment.txt` records `dotnet --info`, configured NuGet sources, and the
supported project set, package-lock hashes, and discovered test-project set;
- `test-results/` contains one TRX per assembly plus any VSTest hang sequence
and dump files;
- `logs/` contains the exact command and complete output for every child
process; and
- `SHA256SUMS.txt` hashes the evidence bundle.
The complete gate runs on Windows because it exercises the full product and
launcher surface. Hosted GitHub Actions execution is deliberately parked as of
2026-08-18 while runner policy is decided; the checked-in workflow definitions
are preserved for later use. Until then, the repository command above is the
authoritative gate. Focused portability or Vulkan jobs are not substitutes for
the complete gate.
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:
```powershell
$env:ACDREAM_RUN_INSTALLED_DAT_TESTS = '1'
$env:ACDREAM_DAT_DIR = 'C:\path\to\Asherons Call'
pwsh ./tools/run-release-gate.ps1 -SkipRestore -SkipBuild `
-TestFilter 'Lane=InstalledDat&Status!=KnownFailure&Purpose!=Diagnostic'
```
The prepared-package lane additionally requires a validated `acdream.pak`
beside the DATs or at `ACDREAM_PAK_PATH`:
```powershell
$env:ACDREAM_DAT_DIR = 'C:\path\to\Asherons Call'
$env:ACDREAM_PAK_PATH = 'C:\path\to\acdream.pak'
pwsh ./tools/run-release-gate.ps1 -SkipRestore -SkipBuild `
-TestFilter 'Lane=PreparedPackage&Status!=KnownFailure&Purpose!=Diagnostic'
```
Regenerate all committed UI fixtures through the one comprehensive manual
generator (the former chat/radar-only generators were redundant):
```powershell
$env:ACDREAM_REGENERATE_UI_FIXTURES = '1'
$env:ACDREAM_DAT_DIR = 'C:\path\to\Asherons Call'
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release `
--filter 'Lane=Manual&ManualTask=FixtureGeneration'
```
The retained live-DAT probes are manual evidence, not InstalledDat regression
contracts. Run each opt-in family independently so a probe command can never
regenerate fixtures as a side effect:
```powershell
$env:ACDREAM_DAT_DIR = 'C:\path\to\Asherons Call'
$env:ACDREAM_PROBE_LIVE_MOUNT = '1'
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release `
--filter 'Lane=Manual&ManualTask=LiveMountProbe'
$env:ACDREAM_PROBE_POWERBAR = '1'
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release `
--filter 'Lane=Manual&ManualTask=PowerbarProbe'
```
Known failures (`Status=KnownFailure`) are never part of a green release total.
Run them explicitly with their prerequisite lane configured; a failure is
expected until the linked defect is fixed. Diagnostic apparatus
(`Purpose=Diagnostic`) likewise reports separately and does not inflate the
contract-test pass count.
The current diagnostic apparatus lives in App and Core. It is retained for
investigation output, and several methods require installed DATs:
```powershell
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release `
--filter 'Purpose=Diagnostic&Lane!=Manual'
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Release `
--filter 'Purpose=Diagnostic&Lane!=Manual'
```
Operating-system contracts are likewise explicit. Run `Lane=Windows` on a
Windows host and `Lane=Linux` on a native Linux host; a lane is not portable
evidence when executed on the other operating system.
`Lane=SystemFont` exercises the BitmapFont path against a host-provided TTF.
It is separate because the supported runtime can legitimately have none of the
well-known development fonts installed.
## Updating dependencies
Do not edit lock files by hand. To make an intentional dependency change:
1. Change the version once in `Directory.Packages.props` (or add/remove a
versionless `PackageReference` in a project).
2. Regenerate the neutral graph and both supported release-RID graphs from the
repository root:
```powershell
pwsh ./tools/update-package-locks.ps1
```
3. Review the central-version and `packages.*.lock.json` diffs.
4. Prove locked resolution and run the gate:
```powershell
dotnet restore AcDream.slnx --locked-mode --force-evaluate
pwsh ./tools/run-release-gate.ps1
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -371,7 +371,7 @@ area files.
> (`ObjectMeshManager.PrepareGfxObjMeshData:1046`,
> `PrepareCellStructMeshData:1394`, `CellMesh.Build:44`,
> `GfxObjMesh.Build:71`), and the fills have no negative surface
> (`Diagnostic_ReplicateProductionEmission_OnPortalFills`: pos=False/neg=False for every
> (`ReplicateProductionEmission_OnPortalFills`: pos=False/neg=False for every
> fill). The equivalence pin (`StipplingSurfaceEquivalenceTests`, 2,607
> polys, 0 violations) proves our build-time skip ⇔ retail's draw-time
> `skipNoTexture` on this content. Consequences: the ledger rows

View file

@ -141,19 +141,6 @@ internal element states `SetDragAcceptState` writes — both are real; the Layou
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
the C++ side. CONFIRMED.
> **Correction 2026-08-08 (spell-bar drop-ring research):** the parenthetical
> above has the accept/reject ids SWAPPED. The true mapping is
> `ItemSlot_DragOver_Accept = 0x10000040 → 0x060011F9` and
> `ItemSlot_DragOver_Reject = 0x10000041 → 0x060011F8`, confirmed by
> DatReaderWriter's retail-derived `UIStateId` enum, by the legal/illegal
> branches of `gmPaperDollUI::HandlePaperDollDragOver` @ 0x004A3AC9/0x004A3AEB
> and `VendorSellUI::OnItemListDragOver` @ 0x004C2327/0x004C2336, and by the
> machine layout dump (`2026-06-25-retail-ui-layout-dump.json`, states
> 268435520/268435521 on elements 0x1000046D/0x1000046C). The table row's
> name→art column above was always right; only this paragraph's numeric
> pairing was inverted (and propagated into
> `2026-07-13-retail-item-drag-visuals-pseudocode.md`, corrected the same day).
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound

View file

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

View file

@ -83,7 +83,7 @@ draw the purple lightning over the floor"):
3. **Striped floor z-fight-like artifact.** User's 2nd screenshot: regular
magenta bands across one floor region, "like something is fighting to draw
the purple over the floor." **NOT attributed.** Ruled out: not coincident dat
geometry (the `Diagnostic_CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep
geometry (the `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep
found only the legit z=12 under-hall floor quad-fan, nothing near the 6
corridor floor); not a striped texture (all corridor surfaces are plain
`Base1Image` stone 0x08000375/6/7/8). Leading guess: two draws of the same
@ -116,7 +116,7 @@ draw the purple lightning over the floor"):
stair geometry owner (`0x8A020182`'s ramp shell, vertical portals, ZERO
statics), CellBSP containment (partitions exactly at portal planes),
under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0
transparent texels), and `Diagnostic_CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
transparent texels), and `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
(the stripe-geometry sweep — came back empty for the 6 floor).
- **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`**
— production-matched portal-flood replays (approach/descent/gaze-sweep/walk +

View file

@ -48,7 +48,7 @@ NOT throw more lighting fixes at it (two already did nothing).
| Lighting **selection** | `SelectForCell` (all dynamic lights per cell, retail-exact) → **no visual change** |
| Light-set **camera-cap churn** (the OLD "confirmed" #176 theory) | visible-cell scoping shipped (`c500912b`); probe-proven ~285 through-floor lights dropped/frame — symptom unchanged. **REFUTED.** |
| **Membership / the "flap"** | `ACDREAM_PROBE_FLAP`: render cell = `0x8A020164` on **100%** of 188,732 frames across **526 distinct camera angles**, `res=None` always; `ACDREAM_PROBE_PVINPUT` flood is stable-per-angle (17/10/8…), never oscillates at a fixed view; 1 `[cell-transit]` (the spawn teleport) total |
| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.Diagnostic_CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=6 corridor floor (only the benign same-cell z=12 under-hall floor tiling in `0x011E`) |
| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=6 corridor floor (only the benign same-cell z=12 under-hall floor tiling in `0x011E`) |
| A2C / alpha-hole see-through | corridor floor surface `0x08000377` is **fully opaque** (`alpha0Texels=0`, `transl=0.00`) |
| Translucent under-surface blend | the one translucent colored surface `0x08000034` is `NoPos` (not drawn) |
| Flat (per-face) normals | corridor floor uses **smooth per-vertex dat normals** (center `(0,0,1)`, corners tilted ~27° — retail-style edge smoothing), NOT flat (`CellVertexNormals_SmoothOrFaceted_Dump`) |
@ -129,7 +129,7 @@ RenderDoc (do NOT assume — capture and read):
- `ACDREAM_PROBE_INDOOR_LIGHT=1``[indoor-light]` scoped-pool SET composition.
- `tools/cdb/issue176-floor-light.cdb` — retail light-setup trace.
- `Issue176177DungeonSeamInspectionTests` — dat truth (coplanar sweep, floor
surfaces, vertex normals); `Diagnostic_CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
surfaces, vertex normals); `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
seed = `{0164,0165,016E,017A}`+neighbors.
## Repro + launch protocol

View file

@ -79,28 +79,12 @@ if target list is a container selector
target.SetDragAcceptState(0x10000046) # ItemSlot_DragOver_DropIn
# 0x060011F7 green arrow
else if target accepts an ordinary item-list placement:
target.SetDragAcceptState(0x10000040) # ItemSlot_DragOver_Accept
target.SetDragAcceptState(0x10000041) # ItemSlot_DragOver_Accept
# 0x060011F9 green circle
else:
target.SetDragAcceptState(0x10000041) # ItemSlot_DragOver_Reject
# 0x060011F8 reject
target.SetDragAcceptState(0x10000040) # 0x060011F8 reject
```
> **Correction 2026-08-08 (spell-bar drop-ring research):** the block above
> originally had the Accept/Reject numeric ids swapped (`0x10000041` labeled
> Accept, `0x10000040` labeled reject). Three primary sources agree the true
> mapping is `ItemSlot_DragOver_Accept = 0x10000040 → 0x060011F9` and
> `ItemSlot_DragOver_Reject = 0x10000041 → 0x060011F8`: DatReaderWriter's
> retail-derived `UIStateId` enum; the legal/illegal branches in
> `gmPaperDollUI::HandlePaperDollDragOver` (`AutoWearIsLegal` → 0x10000040
> @ 0x004A3AC9, else 0x10000041 @ 0x004A3AEB) and
> `VendorSellUI::OnItemListDragOver` (`DragItemAcceptable` → 0x10000040
> @ 0x004C2327, else 0x10000041 @ 0x004C2336); and the machine layout dump
> (`2026-06-25-retail-ui-layout-dump.json`: state 268435520 = 0x10000040 →
> image 0x060011F9, state 268435521 = 0x10000041 → 0x060011F8). The
> art-per-semantic mapping in the shipped code was always correct; only the
> numeric labels here were swapped.
Therefore the backpack contents grid uses the green circle; the side-bag column
and main-pack container cell use the green drop-in arrow. The selected/open
indicators remain visible while `m_elem_Icon_Ghosted` is active, so the

View file

@ -206,56 +206,25 @@ which confirms that worker completion alone is not draw readiness.
### 2.1. Destination placement enters the spatial cell before simulation resumes
> **2026-08-04 correction (C4 route 3, D-T9), itself corrected 2026-08-05
> (R6 retail review):** the listing below attributes portal arrival to
> `player.enter_world(destination)`. That is wrong — a caller sweep of the
> named retail decomp
> (`docs/research/named-retail/acclient_2013_pseudo_c.txt:93770-93828`) shows
> both `CPhysicsObj::enter_world` call sites (pseudo-C `:93797` @0x004550EC
> and `:93824` @0x00455095) living inside **`SmartBox::HandleCreateObject`
> @0x00454C80** — `CObjectMaint::CreateObject` @0x00454FD8 is merely a
> *callee* it invokes partway through, not the enclosing function the first
> correction pass named. The two call sites are also **not both in the
> player branch**: @0x004550EC sits in the `if (arg3 != this->player_id)`
> NON-player branch (`PhysicsDesc::get_position``enter_world` for a
> newly-created REMOTE object); only @0x00455095 sits in the player branch,
> after `SmartBox::init_player` + `CellManager::ChangePosition`. Both sites
> are the LOGIN/CreateObject path that creates a physics object for the
> first time — neither is portal arrival. Portal arrival is
> `SmartBox::TeleportPlayer` (`0x00453910`) → `CPhysicsObj::SetPositionSimple`
> (`0x00453924`/`0x005162B0`) — confirmed by C4 route 3's own §1 citations
> and grep at `acclient_2013_pseudo_c.txt:92514-92521`. The conclusion below
> (commit the cell before releasing simulation) is unaffected —
> `SetPositionSimple` reaches the identical `change_cell`/`update_object`
> machinery this section describes — only the entry-point name and
> pseudocode's `enter_world` call are wrong; read `SetPositionSimple(destination)`
> wherever this section says `enter_world(destination)`.
>
> This routing is `SmartBox::TeleportPlayer``SetPositionSimple`
> everywhere; nothing in the passages below distinguishes retail's specific
> Recall/Lifestone/GM-teleport CAUSES, since they all funnel through the same
> accepted-destination Position at this layer.
Named retail references:
- `CPhysicsObj::change_cell` at `0x00513390`
- `CPhysicsObj::update_object` at `0x00515D10`
- `SmartBox::TeleportPlayer` at `0x00453910`
- `CPhysicsObj::SetPositionSimple` at `0x005162B0`
- `CPhysicsObj::enter_world` at `0x00516170`
- `CPhysicsObj::prepare_to_enter_world` at `0x00511FA0`
- `CPhysicsObj::set_hidden` at `0x00514C60`
Retail does not separate an accepted destination Position from the object's
live cell pointer. `SetPositionSimple` installs the object in its destination
`CObjCell`, before the PartArray and MovementManager enter-world boundaries
complete. `update_object` then rejects only a parented object, a null `cell`,
or a Frozen object; Hidden is not a reason to skip the
live cell pointer. `enter_world` runs `SetPosition`, which installs the object
in its destination `CObjCell`, before the PartArray and MovementManager
enter-world boundaries complete. `update_object` then rejects only a parented
object, a null `cell`, or a Frozen object; Hidden is not a reason to skip the
ScriptManager/ParticleManager tail.
```text
accepted portal destination becomes ready:
SmartBox.TeleportPlayer(destination)
SetPositionSimple(destination)
player.enter_world(destination)
SetPosition(destination)
change_cell(destination CObjCell)
PartArray.HandleEnterWorld()
MovementManager.HandleEnterWorld()

View file

@ -1,682 +0,0 @@
# #265 capture-driven bisection — steep-slope response family
**Status: FIX IMPLEMENTED 2026-07-30 (same day, §9 as-fixed addendum) —
closure pends the user's visual-gate acceptance.** S1 and S2 both CLEARED
for the two concrete mined events; the real mechanism was a pre-existing
(frozen-phase) architecture, not a Campaign P regression — §1-§8 below are
the original bisection pass (research-only, no production code changed
at that point). §9 records what was actually implemented against that
verdict: `PlayerMovementController.cs`'s grounded residual-velocity zero is
removed for the animation-root-motion path, and `PhysicsEngine.cs` now
wires `PhysicsBody.GroundNormal` from the committed contact plane. The
harness (committed,
`tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`)
and mining tool (`tools/analyze_265_steep_slope_capture.py`) are permanent;
the A/B code toggles described in §3 were applied and reverted locally
and never committed (they remain historical — the actual fix is
unrelated to S1/S2, see §9).
## 0. Scope recap
Issue #265 (`docs/ISSUES.md`): after the TS-4-removal-then-revert
(`2e27d066`+`a8a7d64b`), the live matrix gate (2026-07-30, scenarios 4/5)
found three symptoms: (a) jumping INTO an uphill slope bounces (retail does
not), (b) house-roof slides no longer happen, (c) occasional
stuck-sliding-on-an-edge. Two remaining Campaign-P suspects were named:
- **S1**`db2889af` ("#116 shape-1"): `BSPQuery.cs` Path-6's `hasSphere1`
(head-sphere-only hit while airborne, foot sphere clear) branch changed
from a steepness-gated dual path (steep → slide-tangent-then-`Slid`;
shallow → `SetCollide`+`Adjusted`) to an unconditional
`SetCollisionNormal` + `return Collided`.
- **S2** — the AP-7 `calc_friction` threshold rewrite (merge `26e0334a`):
`0.0``0.25`, unconditional into-plane velocity subtraction past the
threshold.
## 1. Segment mining
Captures used: `artifacts/matrix-session2-resolve.jsonl` (15,726 records,
copied from the coordinator worktree's `artifacts/matrix-session2-resolve.jsonl`)
and `artifacts/matrix-session3-resolve.jsonl` (12,145 records at copy time).
**Session3 is not usable.** Every one of its 12,145 records shows the
identical position `(60.372223, 9.071998, 79.344925)`, zero velocity, and
`transientState=3` (Contact|OnWalkable) from tick 0 to tick 12144 — the
player was standing perfectly still (likely AFK / alt-tabbed) for the
entire ~7.2-minute capture window. It contains no motion at all and was
excluded from further analysis.
### 1.1 First pass — strict signature scan (`tools/analyze_265_steep_slope_capture.py`)
Two signatures were scanned for directly on the JSONL fields:
- **Signature A (uphill-jump bounce)**: an airborne record (`bodyBefore`
Contact bit clear) with `result.collisionNormalValid=true` and a "steep,
non-floor, non-wall" normal (`0.02 < normal.Z < FloorZ=0.6642`), followed
by a next-tick upward jump in `bodyBefore.velocity.z` while still
airborne.
- **Signature B (lost-slide / edge-wedge)**: ≥6 consecutive ticks with
Contact set but OnWalkable clear (resting against a non-walkable steep
surface), a non-trivial requested move each tick, and near-zero net
advance.
**Result: 0 hits for both signatures, in both files.** Session2 has only
38 `collisionNormalValid=true` records total (out of 15,726), and every one
of them has `normal.Z` in the `[0.85, 1.0]` bucket — i.e. every reported
collision normal in this capture is CLOSE TO FLAT/floor-like, never in the
"genuinely steep" `< FloorZ` band my first-pass signature targeted. This is
an honest negative result for the specific "steep" heuristic; the actual
symptom-bearing frames, mined below, are moderate-angle (Z≈0.860.95,
above `FloorZ` — walkable BY THE THRESHOLD) and are found by a different
signature.
### 1.2 Second pass — velocity-annihilation scan
Widened the signature to "a tick where `|horizontal velocity|` before is
`>2 m/s` and after (next tick) is `<0.05 m/s`, then how long the position
stays frozen afterward." This found exactly **two events**, both in
session2:
| idx (0-based) | tick | `|v_horiz|` before | frozen for | cell |
|---|---|---|---|---|
| 3153 | 3153 | 8.90 m/s | 46+ ticks (session2 continues past it; not EOF) | `0xAAB30007` |
| **3434** | **3434** | **18.00 m/s** | **12,292 ticks — to EOF** | `0xAAB40011` |
**Event at idx 3433/3434 (the primary oracle for this pass), full trace**
(`records[3415..3434+41]`, printed via ad-hoc Python — see
`tools/analyze_265_steep_slope_capture.py` for the reusable scanner):
- Ticks 34153432: clean ballistic fall. `vBefore = (11.15, 14.13, vz)`
with `vz` accumulating from 16.72 to 23.14 (pure gravity, no further
horizontal drive — a jump/leap with residual momentum, exactly the kind
of trajectory the oracle plan's §1.3 predicted would NOT hit the
TS-4 degenerate case). `Z` falls from 92.79 to 79.90.
- **Tick 3433 (the landing):** `result.collisionNormalValid=true`,
`result.collisionNormal=(0.2857143, 0.42857143, 0.85714287)` — exactly
`(2,3,6)/7`, a REAL polygon normal (not the `UnitZ` degenerate default).
`result.isOnGround=true`. `bodyAfter.contactPlaneValid=true`,
`bodyAfter.walkablePolygonValid=true`, `bodyAfter.walkableVertices` = the
triangle `(240,0,88), (264,0,80), (264,24,68)``normal.Z=0.857`, well
ABOVE `PhysicsGlobals.FloorZ` (0.6642): **a legitimately walkable roof
slope, not the "steep, non-walkable" case either S1 or the original TS-4
shortcut ever targeted.** `bodyAfter.velocity` is UNCHANGED
`(11.15,14.13,23.14)` — confirms (see `PhysicsEngine.cs:1489`, capture
fires inside `ResolveWithTransition`, before any caller-side velocity
response) that neither `calc_friction` nor `HandleAllCollisions` ran yet.
- **Tick 3434 (the very next resolve call):** `input.currentPos ==
input.targetPos` (ZERO requested motion this tick — the previous frame's
integration already produced zero displacement). `bodyBefore.velocity =
(0, 0, 0)` — **already fully zeroed by the time THIS resolve call even
starts.** `transientState=7` (Contact|OnWalkable|Sliding). Every
subsequent record (12,292 of them, to the literal end of the file) is
byte-identical: same position, same zero velocity, same
`transientState=7`.
**Event at idx 3152/3153** is the same shape at a shallower ~18° roof edge
(`normal≈(0,0.32,0.95)`): the player glides/climbs cleanly along the edge
for ~140 ticks (idx 30163152, gaining ~10 m of Z — this portion is
healthy behavior), then at idx 3153 horizontal velocity is forced to
exactly zero in one tick and the position freezes for the rest of the
examined window.
**Both events are the SAME shape**: a real, correct, non-default collision
normal is recorded on the landing tick; on the very next tick the mover's
full horizontal velocity has already vanished and the position never
changes again. This is what the user experiences as "roof slides no
longer happen" (symptom b) and "occasional stuck-sliding-on-an-edge"
(symptom c). Neither event's landing surface is steep by `FloorZ` — both
are moderate, walkable-by-threshold roof pitches.
## 2. Replay harness
`Issue265SteepSlopeCaptureBisectTests.cs` builds a synthetic
`PhysicsEngine` containing ONE polygon — the exact real triangle recovered
from record 3433's `bodyAfter.walkableVertices` — registered via
`ShadowObjectRegistry`, then replays the EXACT real captured ballistic
state (position + velocity, record index 3415) forward with real gravity
at 30 Hz, calling `PhysicsEngine.ResolveWithTransition` every tick exactly
like `PlayerMovementController` does at the Core boundary. Once the mover
reports `IsOnGround`, the harness keeps REQUESTING the same forward
velocity every tick (simulating held input) — this is deliberate: it turns
the harness from "replay what the live game did" (which trivially
reproduces the freeze, since the live game's own subsequent inputs were
already zero — see §4) into "does the physics engine itself allow
continued advance across this surface," which is the actual question S1
and S2 bear on.
### 2.1 Harness commissioning (three real bugs found and fixed while building it — kept as code comments)
1. `ShadowObjectRegistry.Register`'s broad-phase culls by distance from
`worldPos`. Registering at the literal real-world coordinates (X≈256)
while querying at world origin put the polygon ~264 units away — the
very first run found **zero collisions at all**. Fixed by re-anchoring
the whole synthetic scene (triangle + approach trajectory) at the
triangle's centroid.
2. `CellTransit.BuildShadowCellSet`'s outdoor flood
(`CellTransit.AddAllOutsideCells`) treats world position as
landblock-local (an anchor-frame convention shared with
`Ts4SteepRoofWedgeCaptureTests`/`DoorBugTrajectoryReplayTests`, active
whenever `CellGraph.TryGetTerrainOrigin` has no real terrain to
consult). The real-world coordinates (X≈256) are outside the valid
`[0,192)` per-landblock range even after centroid re-anchoring picked a
bad cell id — still zero collisions.
3. `LandDefs.AdjustToOutside` (inside the flood) **silently re-derives**
the actual `(lx,ly)` grid cell from the sphere's real position and
corrects a mismatched seed rather than honoring the literal
`seedCellId` passed to `Register` — an arbitrary chosen cell id
(`0x00000011`) registered successfully (`TotalRegistered=1`) but
`GetObjectsInCell(0x00000011)` came back empty; the entity had actually
landed in cell `0x00000001` (the canonical grid-(0,0) cell, matching
the re-anchored centroid). Switching the harness's cell id to
`0x00000001` fixed it.
These are documented in the test file's code comments in case another
harness hits the same three traps.
## 3. A/B outcomes
### (i) HEAD vs (ii) S1-reverted
The S1 revert (`BSPQuery.cs`'s `hasSphere1` branch restored to the
pre-`db2889af` steepness-gated dual path, mirroring the still-current
`sphere0` branch — applied locally, verified `git diff --stat` clean
before and after, never committed) produced **byte-identical output** to
HEAD for the full 80-tick replay: same landing tick (19), same clean
44-tick glide (ticks 2062, `adv=0.5149` every tick, `cnv=false`), same
freeze at tick 63 (`adv=0.0000` for 16+ consecutive ticks, capped by the
harness's tick budget — it would continue indefinitely), same recorded
`collisionNormal=(-0.958,0.128,0.256)` from that point on.
**Why they're identical — confirmed by diagnostic instrumentation**
(`PhysicsDiagnostics.ProbeIndoorBspEnabled`/`ProbeBuildingEnabled`, the
`[path-dispatch]`/`[path5-diag]` probes `db2889af` itself added): across
the entire 80-tick replay, **`hit1=True` never appears once.** The two
`[path-dispatch] ... collide=True ... contact=False ...` lines (Path 6
firing during the airborne approach) are followed by
`insertType=Placement` (Phase 3's walkable-landing retry succeeding) —
this is the STILL-UNCHANGED `sphere0` (foot) branch's graceful
`SetCollide``Adjusted`→Phase-3-Placement chain, not the `hasSphere1`
branch S1 touched. Once grounded, every subsequent Path-5 dispatch reports
`hit0=False hitPoly0=False` then `hit1=False hitPoly1=False` — a genuinely
clean glide with no collision at all, which is why the S1 edit (which only
fires inside `if (hit1 || hitPoly1 is not null)`) never executes for this
trajectory. **S1's site is provably unreached by the real mined
trajectory that produced the freeze.** Reverting code that never runs
cannot change the outcome — this is not a coincidence, it's the direct
mechanical explanation.
### (iii) S2 toggle
**Not run as a harness A/B — proven inert by static analysis instead.**
`grep -rn "\.calc_friction(" src/` returns **zero production call sites**
the only callers of `PhysicsBody.calc_friction` in the entire repository
are its own unit tests (`tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs`).
`PlayerMovementController.cs` mentions it only in a code comment
(line ~2021, "friction next frame") — it is never invoked. Neither
`ResolveWithTransition` nor `PlayerMovementController`'s tick loop calls
`calc_friction` anywhere. **S2's threshold value (0.0 vs 0.25) cannot
affect any live or replayed behavior, full stop** — there is no toggle to
run because there is no live code path to toggle.
### (iv) Both reverted
Follows immediately from (ii) and (iii): with S1 reverted producing
byte-identical output to HEAD, and S2 provably inert, the "both" variant
is mathematically identical to (ii), which is identical to (i). No
separate run was needed.
### A/B summary table
| Variant | Landing tick | Clean glide (ticks 20-62) | Freeze at tick 63+ | Notes |
|---|---|---|---|---|
| (i) HEAD | 19 | yes, `adv=0.5149`/tick | yes, frozen forever | `hit1` never true |
| (ii) S1 reverted | 19 (identical) | yes (identical) | yes (identical) | S1's branch unreached |
| (iii) S2 toggle | n/a | n/a | n/a | dead code, no call sites |
| (iv) both | 19 (identical) | yes (identical) | yes (identical) | follows from (ii)+(iii) |
## 4. The actual mechanism (found by hand-tracing the live capture against `PlayerMovementController.cs`, independently confirming it explains BOTH mined freeze events exactly)
Neither S1 nor S2 touch velocity. The full-zero-in-one-tick signature
(§1.2) is produced by two pre-existing, Campaign-P-independent pieces
working in sequence:
1. **The landing tick** (`PlayerMovementController.cs`, the
`if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)` block):
Contact+OnWalkable are set, and — because `Velocity.Z < 0` — ONLY the
Z component is hand-zeroed: velocity becomes `(11.15, 14.13, 0)`.
`PhysicsObjUpdate.HandleAllCollisions` then runs with `shouldReflect =
true` (the mover was airborne the frame before: `prevOnWalkable=false`
makes `shouldReflect` unconditionally true regardless of the new
grounded state — see `PhysicsObjUpdate.cs:163-164`). But
`dot(velocity, collisionNormal) = dot((11.15,14.13,0),
(0.286,0.429,0.857)) ≈ +9.25` — POSITIVE (moving away from, not into,
the surface, because the Z component that would have made it negative
was just zeroed) — so the `if (dot < 0f)` reflection guard
(`PhysicsObjUpdate.cs:177`) never fires. Velocity survives this tick as
`(11.15, 14.13, 0)`.
2. **The very next tick** (`PlayerMovementController.cs:1868-1882`, added
2026-07-20 by `f961d700`, "port retail complete object frame
pipeline" — R6, well before Campaign P):
```csharp
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
if (hasAnimationRootMotion)
{
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
}
...
}
```
`OnWalkable` is now true (set last tick), so this runs UNCONDITIONALLY,
EVERY tick, for as long as the mover stays grounded: it zeros
`Velocity.X/Y` to exactly zero (`savedWorldVz` is already 0 from step
1), replacing physics-integrated horizontal velocity with
animation-root-motion-driven displacement (`pmDelta.Origin`, populated
from `_advanceAnimationRootMotion`, which only produces nonzero
displacement when a movement key is actually held). **With no key held
at the instant of landing, `pmDelta.Origin` stays `Vector3.Zero` forever,
and the mover never advances again.** This reproduces `bodyBefore.velocity
= (0,0,0)` at record 3434 exactly, and the permanent freeze that follows.
This is the R6 "local player animation-owned grounded movement"
architecture: once grounded, walking is driven entirely by held-input +
animation root motion, not by integrating `Velocity`. It has been in
place since 2026-07-20 — **ten days before Campaign P and the TS-4
removal/revert (2026-07-29/30)** — and is explicitly a frozen-phase
architecture per the milestones doc (R6 shipped; the freeze list bars
rework without a dedicated brainstorm). It is retail-DIVERGENT in one
specific way that matters here: retail does not need a held key to carry
residual momentum across a landing — a fast fall onto a walkable-but-
sloped surface should glide/sled per `docs/ISSUES.md` #166 ("Slope-landing
glide + bounce absent... acdream lands clean and dead"), which is filed,
open, and explicitly OUT OF SCOPE for this pass (the `Sledding`
`PhysicsStateFlags` bit that would let `calc_friction`'s Sledding-gated
overrides engage is never set anywhere in the codebase — a separate,
already-tracked gap).
`git log --oneline -3 -- src/AcDream.Core/Physics/PhysicsObjUpdate.cs`
confirms `HandleAllCollisions` itself was also last touched by an
unrelated water fix (AP-10, `cc8d57a2`) — Campaign P did not modify it
either.
## 5. Re-reading the oracle plan's S1 claim against the mined evidence
The task asked specifically: if S1's port is faithful but its SCOPE is
wrong, say exactly that. Re-checked against
`docs/research/2026-07-30-ts4-116-oracle-plan.md` §2.3-§2.4 and §3, plus
this pass's own finding:
- **S1's port IS faithful in isolation.** Its cited sources
(`acclient_2013_pseudo_c.txt:323824-323834`, ACE `BSPTree.cs:221-230`)
are an exact structural match — not a BN misdecompile, not a citation
error. This was independently re-verified by reading the current
`BSPQuery.cs:2259-2302` against the same two sources again this pass; no
discrepancy found.
- **S1's scope is narrower than any symptom this pass could reproduce —
not wider.** The oracle plan's own Addendum 2 (§"implementation
session") already found this exact pattern once, for the door
tick-22760 capture: the hypothesis assumed the "not-yet-in-Contact"
branch would fire, but the mover was actually GROUNDED (`Contact` set),
so dispatch went to Path 5 instead and S1's site was never reached. This
pass finds the SAME pattern a second, independent time, for a
DIFFERENT capture (a genuine airborne fall, not a grounded door-push):
the foot sphere (`sphere0`) reaches the rising/sloped polygon at the
same moment as or before the head sphere, so the `if (hit0 ||
hitPoly0 is not null)` branch above `hasSphere1`'s check fires first and
RETURNS before `hasSphere1`'s block is ever entered
(`BSPQuery.cs:2188` gates the whole `hasSphere1` block behind falling
through that first `if`). `hit1=True` never appears once across the
entire 80-tick replay, confirming this mechanically, not just by
inference.
- **Two independent capture families (a grounded door-push, and now an
airborne fall-and-land) both show S1's site going unreached.** This
strongly suggests S1's real-world reach is much narrower than its
authors worried — for it to matter, a trajectory would need the FOOT
sphere to stay clear while the HEAD sphere alone grazes a polygon
during an airborne (not-yet-grounded) frame — e.g. jumping up under an
overhang, or clipping a roof's underside while airborne with the feet
still below the eave line. **Neither of #265's two concrete mined
freeze events is that geometry.** S1 remains a real, citable, retail-
faithful port-accuracy improvement and should NOT be reverted on this
evidence (it fixes a genuine, if narrow, divergence for whenever its
exact geometry does occur) — but it is not implicated in the symptoms
#265 was filed against.
## 6. Named culprit
**Neither S1 nor S2. This is S3 — but not a NEW regression: it is the
pre-existing, frozen-phase R6 "grounded movement is animation-root-motion-
owned" architecture (`PlayerMovementController.cs:1868-1882`, landed
2026-07-20 via `f961d700`, ten days before Campaign P), which
unconditionally zeros the mover's horizontal `Velocity` every tick once
`OnWalkable` is true, with no gate on approach speed, surface steepness,
or how the mover became grounded.** It was mechanically traced, tick by
tick, against BOTH of #265's concrete mined freeze events and reproduces
the observed `(0,0,0)` velocity and permanent position-freeze exactly.
This explains symptom (b) (roof slides don't continue — there is no
"continue," walking requires a held key that landing doesn't supply) and
symptom (c) (stuck at the landing spot indefinitely) completely, for both
mined events. It does **not**, by itself, explain symptom (a) (the
"bounce" on jumping into an uphill slope) — that is a property of
`PhysicsObjUpdate.HandleAllCollisions`'s elastic reflection (`shouldReflect
= true` whenever the mover was NOT already on walkable ground before AND
after the resolve — `PhysicsObjUpdate.cs:163-164`), which is ALSO
pre-existing (from the #182 rebuild, well before Campaign P) and fires for
ANY valid `CollisionNormal` reported while airborne, regardless of which
BSPQuery branch produced it. This pass did not find or replay a concrete
"bounce" event in the captures (the closest analogue — the tick-63
edge-freeze in the replay harness — shows a suspicious secondary normal,
`(-0.958,0.128,0.256)`, unrelated to the registered polygon's own plane
normal, with Path-5 diagnostics showing no fresh BSP hit during the frozen
ticks; this smells like stale `ContactPlane`/`CollisionNormal` persistence
at a polygon boundary rather than a fresh reflection, and — like the S1
revert — was unaffected by reverting S1. It is flagged as a genuine open
question, not resolved this pass, and may be an artifact of this
harness's single small (24-unit) synthetic triangle rather than a general
production bug; a real roof's continuous mesh would not present a "run off
the edge of a 24-unit patch" boundary at all. See §7).
## 7. What's still open (do not guess, per CLAUDE.md)
1. **Why does the user perceive this as a NEW regression coinciding with
Campaign P**, if the freeze mechanism (§4) predates it by ten days and
is unaffected by S1/S2? Two honest hypotheses, neither confirmed:
(a) the roof-jump/fall scenario was specifically exercised for the
FIRST time as part of the Campaign P visual matrix (scenarios 4/5),
surfacing a pre-existing bug rather than a new one; (b) a genuinely
separate, not-yet-isolated interaction exists. Resolving this needs
either a live retail-vs-acdream side-by-side of the EXACT same
fall-and-land-with-no-input scenario pre-Campaign-P (to confirm the
freeze is not new), or a fresh capture of the user's ACTUAL "roof
slide" repro (holding a movement key throughout, not a passive fall) to
see whether the animation-root-motion path (which DOES produce
displacement while a key is held) also fails.
2. **The tick-63 edge freeze** in this pass's own harness (§6, closing
parenthetical) — a `CollisionNormal` unrelated to the registered
polygon's plane, reported while Path-5 diagnostics show no fresh hit.
Candidate next step: extend the harness's synthetic roof to several
contiguous polygons (removing the small-triangle-edge artifact) and
re-run; if the freeze persists on a much larger interior region, it is
a real, separate, third mechanism worth its own root-cause pass
(possibly `SpherePath.PrecipiceSlide`'s edge-crossing test, or stale
`LastKnownContactPlane` persistence — NOT yet confirmed, do not guess
further).
3. **Symptom (a)'s bounce** was analyzed only by static code reading
(`HandleAllCollisions`'s reflection math), not independently reproduced
against a live-captured bounce event — none of the 38
`collisionNormalValid=true` records in session2 showed the "airborne,
then a large upward `Velocity.Z` jump next tick" signature this pass's
Signature-A scanner looked for. A fresh capture specifically of a
jump-into-an-upward-slope repro (ideally with `ACDREAM_PROBE_RESOLVE=1`
or `ACDREAM_CAPTURE_RESOLVE` active for the WHOLE approach, not just
the moment of impact) would let Signature A actually fire and give a
concrete oracle the way records 3433/3434 did for the freeze.
## 8. Recommended fix direction
**Do not touch S1** (`BSPQuery.cs`'s `hasSphere1` branch) — it is a real,
narrow, retail-faithful improvement unrelated to #265's two concrete mined
events; reverting it would only reopen the #116 shape-1 door-collision gap
it was written to close, for zero benefit here.
**Do not spend further effort on S2** (`calc_friction`'s threshold) until
it is actually wired into a live code path — right now changing it changes
nothing observable, in either direction. If/when `calc_friction` IS wired
into `PlayerMovementController` (a legitimate future piece of closing #166,
the downhill-sled issue), the 0.25 threshold becomes live and worth
re-testing at that point, not before.
**The real target is #166 + the grounded-movement architecture (§4/§6),
which is a frozen-phase design question, not a quick fix.** Per CLAUDE.md's
"the roadmap and the observed bug disagree → brainstorm before writing
code" rule, this needs `superpowers:brainstorming` before any
implementation: does acdream want a genuine physics-driven momentum carry
across a landing (porting the retail `Sledding` state + a real
`calc_friction` wiring), or a narrower "if IsOnGround at high incoming
speed, force a minimum coast distance regardless of held input" patch? The
former is retail-faithful and already has a filed target (#166); the
latter would be a new, unfiled design decision. Either way, this is
explicitly NOT an S1/S2 code change — it is new work against
`PlayerMovementController.cs`'s grounded-movement block and
`PhysicsBody.calc_friction`'s wiring, gated on a design conversation, not a
revert.
## 9. As-fixed addendum (2026-07-30, same day — implementation session)
The user chose the retail-faithful direction (§8's first option): port the
genuine physics-driven momentum carry, wiring `calc_friction` for real
rather than adding a narrower coast-distance patch. Implementation
landed the same day as this bisect.
### 9.1 The fix
Two changes, both minimal and at the exact commit points already
responsible for the adjacent state:
1. **`src/AcDream.Core/Physics/PhysicsEngine.cs`** — `body.GroundNormal`
(the vector `calc_friction` dots velocity against, per its own doc
comment "`angle = dot(velocity, contactPlane.N)`") had **zero
production writers anywhere** before this fix; it silently defaulted
to `Vector3.UnitZ` forever (`grep -rn "GroundNormal\s*=" src/` found
only the property's own default and calc_friction's internal reads/
writes). This is a SEPARATE gap from the one §4 found — even if
Velocity had survived the grounded-tick zero, friction would have
dotted it against a fake flat-ground normal on any real slope,
producing wrong physics. Fixed by syncing
`body.GroundNormal = ci.ContactPlane.Normal` (or
`ci.LastKnownContactPlane.Normal`) at the exact block
(`PhysicsEngine.cs` ~:1297-1320) that already publishes
`body.ContactPlane`/`ContactPlaneValid` after every resolve — Core-level,
so player, remote, ordinary, and projectile movers all get a real
slope normal for free (matching the task's "the mechanism is general"
requirement; the ordinary/remote physics updaters
(`RuntimeOrdinaryPhysicsUpdater.cs`, `RuntimeRemotePhysicsUpdater.cs`)
already compose root motion + `UpdatePhysicsInternal` cleanly, with no
destructive zero — this fix brings the player path in line with its
own siblings, not a novel invention).
2. **`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`** — the
grounded-tick block §4 identified (`if (_body.OnWalkable) { ... if
(hasAnimationRootMotion) _body.Velocity = new Vector3(0f, 0f,
savedWorldVz); ... }`) no longer reconstructs `Velocity` AT ALL for the
`hasAnimationRootMotion` case (production graphical local-player
path). The condition is now `if (_body.OnWalkable &&
!hasAnimationRootMotion)`, so ONLY the headless/test-controller
`get_state_velocity` fallback (unchanged) still writes velocity here.
Root motion continues to fully own commanded locomotion (walking
displacement still comes from `pmDelta.Origin`, never from
`Velocity`) — this does not reintroduce command- or packet-cadence-
derived grounded translation (the DO-NOT-RETRY rule in
`claude-memory/project_physics_collision_digest.md`); it only stops
DESTROYING whatever `Velocity` already holds. The existing
`preIntegratePos`/`postIntegratePos` bracketing (root-motion apply,
then `calc_acceleration()` + `UpdatePhysicsInternal(tickDt)`, then
`ResolveWithTransition(preIntegratePos, postIntegratePos, ...)`) was
ALREADY structurally correct for composing both channels — retail's
`CPhysicsObj::UpdatePositionInternal` composition model — so no
further restructuring was needed once the destructive zero was
removed.
### 9.2 Fixture results (freeze → slide, proven)
`Issue265SteepSlopeCaptureBisectTests.cs` gained a `ComposedTickSample`
harness (`ReplayRealRoofLandingComposed`) that mirrors
`PlayerMovementController.cs`'s per-tick composition line-for-line using
only Core types (`PhysicsBody`, `PhysicsObjUpdate.HandleAllCollisions`,
`PhysicsEngine`), parameterized by a
`preserveResidualVelocityOnGroundedTick` toggle representing the old vs.
new shape:
- **`ComposedRoofLanding_OldZeroingModel_ReproducesTheMinedFreeze`**
(toggle `false`): reproduces the exact mined signature — velocity forced
to `(0,0,0)` the tick after landing, frozen solid (`FrozenStreak` grows
unbounded) for the rest of the replay.
- **`ComposedRoofLanding_NewFix_VelocitySurvivesAndPositionKeepsAdvancing`**
(toggle `true`): the SAME captured landing (velocity `(11.15, 14.13,
-23.14)` onto the real `(2,3,6)/7` roof normal) now survives the Z-only
hand-zero with its full horizontal speed, and the position advances
every single tick (`adv=0.5149` per tick, `onWalk=true`, `frozen=0`)
for the entire post-landing window — a genuine sustained glide, not a
freeze. (The original small real-captured triangle had to be enlarged
6x about its centroid — same plane, same normal, same landing point/tick,
see `MakeRoofEngine`'s new `scale` parameter — because the real glide
travels ~50 m over the test window and would otherwise run off the
tiny real triangle's edge into the SEPARATE small-triangle-boundary
artifact §7 item 2 already flagged; that artifact is confirmed
real and unrelated to this fix, see §9.4.)
- **`ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction`**:
a synthetic variant (same roof polygon, a deliberately different
approach velocity chosen so `dot(velocity, GroundNormal) < 0.25` after
landing) proves genuine exponential decay: speed at landing ≈ 6.0 m/s
decays tick-by-tick down to the `SmallVelocitySquared` hard-zero floor
by roughly tick 33 after landing — retail's `calc_friction` formula
working exactly as ported.
**Important nuance:** the REAL captured landing (record 3433's velocity
and normal) happens to fall in retail's "moving away fast enough, no
friction" band (`dot(velocity, GroundNormal) ≈ +9.25 ≥ 0.25`) — so it
glides at CONSTANT velocity across the roof rather than visibly decaying.
This is not a bug; retail's own `calc_friction` early-returns in exactly
this case (the velocity's horizontal projection points "downhill," same
direction as the normal's horizontal projection — see the derivation in
§9.3). The task's framing ("decays over subsequent ticks") is
demonstrated by the separate synthetic case above, which deliberately
selects a velocity/normal pairing where retail's own formula calls for
decay; the real mined case demonstrates the OTHER correct retail outcome
(sustained glide) for its own geometry. Both are "survives and slides,"
never "freezes" — the actual acceptance bar.
### 9.3 Downhill direction derivation (for the synthetic decay case)
For a planar triangle with outward normal N and any point P on the
plane, `dot(N, P - centroid) = 0` (coplanarity). For a slope where Z
increases as you move "uphill," the outward normal's horizontal
projection points toward LOWER Z (downhill) — e.g. plane `z = m·x`
(uphill as x increases) has normal `∝ (-m, 0, 1)`, whose horizontal
component `-m` points toward decreasing x (downhill). The real captured
roof normal `(0.2857, 0.4286, 0.8571)` has horizontal projection
`(0.2857, 0.4286)` pointing downhill; the captured velocity's horizontal
component `(11.15, 14.13)` points in nearly the same direction (both
positive, roughly proportional) — i.e. the mover is genuinely sliding
DOWN and AWAY from the impact point, which is exactly why
`dot(velocity, normal)` comes out strongly positive and friction
correctly declines to engage.
### 9.4 Runtime-level regression tests + a second, unrelated mechanism found
`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`
gained two tests exercising the REAL `PlayerMovementController` (not just
the Core-level model):
- **`Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix`**:
ordinary root-motion walking (no fall/collision in flight) advances by
exactly the authored per-tick delta for 30 ticks and `BodyVelocity`
stays exactly zero throughout — confirming the fix is a complete no-op
for the common "just walking around" case, pinning the L.3c hazard
(`claude-memory/project_physics_collision_digest.md`'s DO-NOT-RETRY
table) at the Runtime level in addition to the existing
`GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`
Core-level pin (unmodified, still green).
- **`Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen`**:
a real charged running jump (forward + jump, full production dispatch)
lands on flat ground and its residual horizontal speed survives the
first post-landing tick, then measurably decays (flat ground:
`dot(velocity, (0,0,1)) ≈ 0 < 0.25`, so friction DOES engage here,
unlike the real roof capture above).
**Building this test surfaced a second, genuinely separate,
already-registered mechanism** (temporary `Console.WriteLine`
instrumentation was added and fully removed per CLAUDE.md's diagnostic-
logging discipline): `MotionInterpreter.LeaveGround()`
(`CMotionInterp::LeaveGround` 0x00528b00, R3-W4/J7/J8, unrelated to
#265/#166) recomputes and OVERWRITES `PhysicsObj.Velocity` from
`GetLeaveGroundVelocity()` on the grounded→airborne edge, using whatever
forward command is interpreted AT THAT EXACT TICK — a real, intentional,
already-ported retail behavior. Releasing the forward key in the SAME
tick this edge fires (an early test-construction mistake, not a
production concern) clobbers the just-launched velocity. Separately,
`MotionInterpreter.ApplyCurrentMovementInterpreted`'s AP-77
"animation-less/headless movement fallback" (register row AP-77,
already correctly scoped: "When `MotionInterpreter.DefaultSink` or the
local PartArray callback is absent...") ALSO rewrites grounded velocity
from `get_state_velocity()` on every `HitGround`/`LeaveGround` re-apply
when no `DefaultSink` is wired — which is exactly the state of a
`PlayerMovementController` built directly in a unit test without wiring
one. Production (`GameWindow`) always wires a real `DefaultSink`, so
neither mechanism is live there; the fixed test (1) holds Forward for one
extra tick so `LeaveGround`'s one-time recompute captures the real
launch velocity before releasing it, and (2) wires a minimal
`FakeAnimationDispatchSink` as `controller.Motion.DefaultSink` so
`ApplyCurrentMovementInterpreted` takes its real dispatch branch instead
of the AP-77 fallback — making the test representative of the production
graphical path rather than the headless one. **Neither mechanism
required any production code change or register update** — AP-77's row
already accurately describes its scope, and `LeaveGround`'s behavior is
intentional retail-ported behavior, not a bug this task touches.
### 9.5 Symptom (a), the uphill bounce — confirmed separate, unaffected
Re-derived `PhysicsObjUpdate.HandleAllCollisions`'s `shouldReflect` gate
byte-for-byte against the raw retail decomp
(`acclient_2013_pseudo_c.txt:282647-282760`,
`CPhysicsObj::handle_all_collisions`) this session:
`var_10_1` (== `shouldReflect`) ends up `!(arg4 && (transient_state & 2)
!= 0 && !sledding)` where `arg4` is `prevContact`/`prevOnWalkable`
captured at `SetPositionInternal` entry (before this call's own commits)
and `transient_state & 2` is read live inside `handle_all_collisions`
itself — i.e. AFTER `set_on_walkable` has already committed the
DESTINATION's OnWalkable bit. This is **exactly** `PhysicsObjUpdate.
HandleAllCollisions`'s existing `shouldReflect = !(prevOnWalkable &&
nowOnWalkable && !sledding)` — a byte-exact port, not a translation bug.
For ANY fresh landing from airborne (`prevOnWalkable=false`), retail
itself reflects whenever the collision normal shows "moving into the
surface" (`dot < 0`), REGARDLESS of whether the destination is walkable.
This is the SAME mechanism AD-25 closed (2026-07-30, Campaign P Slice
P3, docs/ISSUES.md #166) for both local and remote movers — confirmed
pre-existing and out of scope for this task, matching CLAUDE.md's "do
not fix code that matches retail" rule.
`UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix`
(`Issue265SteepSlopeCaptureBisectTests.cs`) constructs a synthetic
30°-uphill walkable slope, a falling-forward approach with `dot(velocity,
normal) < 0` by construction, and runs `HandleAllCollisions` with and
without the residual-velocity-preserving toggle applied AFTERWARD. The
reflection decision (and its resulting velocity) is identical either way
— proving the #265/#166 fix is orthogonal to whatever
`HandleAllCollisions` decides, not a cause of or a fix for the bounce.
The test's own log line documents the specific synthetic case DOES
reflect (`Vz` goes from `0` to `+2.27` on this exact input), consistent
with retail's byte-exact algorithm — evidence for a future dedicated pass
if the user's live repro still shows an unwanted bounce, not a verdict
this task renders.
### 9.6 Test/file summary
- `src/AcDream.Core/Physics/PhysicsEngine.cs``GroundNormal` sync.
- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` — grounded
block no longer zeros `Velocity` for the animation-root-motion case.
- `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`
`MakeRoofEngine`'s new `scale` parameter, `ComposedTickSample` +
`ReplayRealRoofLandingComposed`, and four new `[Fact]`s (old-model
freeze pin, new-fix slide proof, synthetic decay proof, uphill-bounce
orthogonality proof).
- `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`
`FakeAnimationDispatchSink` + two new `[Fact]`s (walk-speed no-op pin,
real running-jump landing survival+decay pin).
- `docs/ISSUES.md`#265 and #166 updated (fix implemented, closure
pends the user's visual-gate acceptance).
- `docs/architecture/retail-divergence-register.md` — AP-7's retirement
note corrected (the 0.25f threshold port was always right; it had
nothing real to operate on until this fix closed both the grounded-
velocity-zero and the `GroundNormal`-wiring gaps). No new row filed —
this change ports retail's mechanism faithfully; it does not introduce
a new deviation.
### 9.7 Verification
`dotnet test` (Release): 4074 Core tests / 2 skips, 434 Runtime tests / 0
skips, 3971 App tests / 3 skips — all green, no regressions. Complete
solution suite (9 projects): 9993 total, 9988 passed, 5 skipped, 0
failed.

View file

@ -1,713 +0,0 @@
# Animation system parity audit — retail vs acdream (2026-07-30)
**Status: COMPLETE — report-only investigation, no code changes made.**
Scope: `CMotionInterp` / `CSequence` / `MotionTableManager` / `CMotionTable`
retail method surfaces vs acdream's `src/AcDream.Core/Physics/MotionInterpreter.cs`,
`src/AcDream.Core/Physics/Motion/*`, `src/AcDream.Core/Physics/AnimationSequencer.cs`,
`src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`.
**Bottom line:** the core animation-selection/playback stack
(`MotionInterpreter`/`CSequence`/`CMotionTable`/`MotionTableManager`) is a
faithful, heavily retail-cited port. All 103 symbols.json-listed retail
methods across the four classes were enumerated (section 1); only one is a
genuine unexplained gap (`get_adjusted_max_speed`) and the rest of the
"unported" set is verified dead code in retail itself. All 7 traced
feel-visible flows (section 2) came back parity. The real, actionable gaps
are narrow and listed in section 6's ranked catalog: an animated-emote
authoring gap (AD-57), two already-known hook-timing residuals (TS-50/
TS-51) precisely rescoped against current code, one likely-already-fixed
issue (#64) needing only live re-verification, and two untraced
combat/casting-layer questions flagged for a future audit outside this
scope.
## Note on the requested prior deep-dive doc
The task referenced `docs/research/2026-06-04-animation-sequencer-deep-dive.md`
as prior art. That file does not exist in this worktree or anywhere in git
history (`git log --all --diff-filter=A -- "*animation-sequencer-deep-dive*"`
returns nothing). `claude-memory/MEMORY.md` indexes it, and a same-named
**skill** (`acdream-animation-sequencer-deep-dive`) exists that would
presumably generate such a doc, but no prior run's output is present at that
path or any other. The closest prior-art documents actually in the repo are:
- `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` (D1-D12
divergence list, dated before the R6/J-slice root-motion work — most of its
wire-format findings, D1/D3/D4/D9, have likely since been superseded by
TS-33/TS-47 and the R6 root-motion campaign; treated as historical baseline
only, re-verified against current code below, not trusted at face value)
- `docs/research/acclient_animation_map.md`, `docs/research/acclient_animation_pseudocode.md`
- `docs/research/2026-04-21-animation-audit.md`, `docs/research/2026-04-28-combat-animation-planner.md`
- `docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md`
- `docs/research/2026-07-02-inbound-motion-verbatim-port-handoff.md`
This audit proceeds using those plus the R6 sections of
`claude-memory/project_physics_collision_digest.md` (which describes what R6
already shipped — not re-audited here per task instructions) and a fresh
grep sweep of the named retail decomp.
---
## 1. Method-coverage sweep
Two independent passes fed this section: a dedicated method-coverage-sweep
sub-agent did a symbol-by-symbol enumeration against `symbols.json` +
pseudo-C call-site tracing, and the lead auditor separately read essentially
the entirety of all four files directly (`MotionInterpreter.cs` ~2,600 of
3,182 lines read in full, `CSequence.cs`/`CMotionTable.cs`/`MotionState.cs`/
`AnimationSequencer.cs` read in full) and independently confirmed the
sub-agent's headline finding (`GetMaxSpeed()` is the only max-speed accessor
anywhere in the App/Runtime call sites — `grep` for `AdjustedMaxSpeed`/
`get_adjusted_max_speed` across `src/` returns zero hits outside the doc
comment that names it as unported). The two passes agree; findings below are
merged, with the sub-agent's table format preserved since it's more scannable
than prose.
**Methodology note (symbol-artifact class):** `symbols.json` occasionally
attributes a class method name to an address whose pseudo-C body is a
different, unrelated function, or lists two names at the identical address
(most likely `/OPT:ICF` identical-code-folding at link time collapsing
byte-identical trivial bodies, with the PDB keeping multiple aliases for one
surviving address). Confirmed instances: `CMotionInterp::HandleEnterWorld`
@ `0x00694750` resolves to `IDClass<>::~IDClass` (unrelated template
destructor); `CMotionInterp::InqStyle` @ `0x00527B10` resolves to
`CBaseFilter::GetPinVersion` (unrelated DirectShow class); `MotionTableManager::RemoveLinkAnimations`
and `HandleEnterWorld` are both listed at `0x0051BDD0` (only one body exists
there); `CMotionTable::Allocator`/`Allocate` are both listed at `0x004F96E0`.
These are marked **SYMBOL-ARTIFACT** below rather than forced into
ported/unported, since the decomp genuinely cannot answer what (if anything)
that distinct method does.
Also confirmed independently on both classes that have a `Pack`/`UnPack`
family (`CSequence`, `CMotionTable`): these `PackObj`/`DBObj` serialization
methods have **zero call sites anywhere in the 1.4M-line pseudo-C** outside
their own bodies and a `.rdata` vtable-slot registration — dead code
inherited from a shared server/client engine base, never invoked by the
retail client itself. Their absence in acdream is correctly not a gap.
### CMotionInterp (41 symbols.json entries)
| Retail method (addr) | acdream status | Cite |
|---|---|---|
| PerformMovement (0x00528E80) | ported-with-cite | `MotionInterpreter.cs:820` |
| DoMotion (0x00528D20) | ported-with-cite | `MotionInterpreter.cs:880,932` |
| StopMotion (0x00528530) | ported-with-cite | `MotionInterpreter.cs:982,1009` |
| StopCompletely (0x00527E40) | ported-with-cite | `MotionInterpreter.cs:1078` |
| get_state_velocity (0x00527D50) | ported-with-cite | `MotionInterpreter.cs:1188` |
| adjust_motion (0x00528010) | ported-with-cite | `MotionInterpreter.cs:1290` |
| apply_run_to_command (0x00527BE0) | ported-with-cite | `MotionInterpreter.cs:1355` |
| apply_raw_movement (0x005287E0) | ported-with-cite | `MotionInterpreter.cs:1398,1498` |
| apply_current_movement (0x00528870) | ported-with-cite | `MotionInterpreter.cs:1459` |
| ReportExhaustion (0x005288D0) | ported-with-cite | `MotionInterpreter.cs:1619` |
| SetWeenieObject (0x00528920) | ported-with-cite | `MotionInterpreter.cs:1671` |
| SetPhysicsObject (0x00528970) | ported-with-cite | `MotionInterpreter.cs:1721` |
| jump_charge_is_allowed (0x00527A50) | ported-with-cite | `MotionInterpreter.cs:1761` |
| charge_jump (0x005281C0) | ported-with-cite | `MotionInterpreter.cs:1826` |
| jump (0x00528780) | ported-with-cite | `MotionInterpreter.cs:1883` |
| get_jump_v_z (0x00527AA0) | ported-with-cite | `MotionInterpreter.cs:1920` |
| get_leave_ground_velocity (0x005280C0) | ported-with-cite | `MotionInterpreter.cs:1958` |
| jump_is_allowed (0x005282B0) | ported-with-cite | `MotionInterpreter.cs:2026,2051` |
| contact_allows_move (0x00528240) | ported-with-cite | `MotionInterpreter.cs:2123` |
| add_to_queue (0x00527B80) | ported-with-cite | `MotionInterpreter.cs:2164` (`AddToQueue`) |
| motions_pending (0x00527FE0) | ported-with-cite | `MotionInterpreter.cs:2173` |
| MotionDone (0x00527EC0) | ported-with-cite | `MotionInterpreter.cs:2193` |
| HandleExitWorld (0x00527F30) | ported-with-cite | `MotionInterpreter.cs:2245` |
| is_standing_still (0x00527FA0) | ported-with-cite | `MotionInterpreter.cs:2266` |
| motion_allows_jump (0x005279E0) | ported-with-cite | `MotionInterpreter.cs:2310` |
| LeaveGround (0x00528B00) | ported-with-cite | `MotionInterpreter.cs:2373` (independently read in full) |
| HitGround (0x00528AC0) | ported-with-cite | `MotionInterpreter.cs:2425` (independently read in full) |
| enter_default_state (0x00528C80) | ported-with-cite | `MotionInterpreter.cs:2483` |
| set_hold_run (0x00528B70) | ported-with-cite | `MotionInterpreter.cs:2521` |
| SetHoldKey (0x00528BB0) | ported-with-cite | `MotionInterpreter.cs:2575` |
| get_max_speed (0x00527CB0) | ported-with-cite | `MotionInterpreter.cs:2632` (`GetMaxSpeed`; doc comment includes a forensic re-derivation of the ×4.0 constant from raw x87 disassembly, UN-2 resolved) |
| **get_adjusted_max_speed (0x00527D00)** | **UNPORTED-UNEXPLAINED** | None found — independently confirmed (see intro above). Sibling of `get_max_speed`; retail `InterpolationManager::adjust_offset` (pc:353107, `0x00555dbe`) chooses between the two via a static toggle `fUseAdjustedSpeed_`. acdream's dead-reckoning catch-up path (`RemoteMotionCombiner.cs:90`, `RuntimeRemotePhysicsUpdater.cs:254,291,685`) only ever calls `GetMaxSpeed()`. No register/ISSUES row covers this. |
| move_to_interpreted_state (0x005289C0) | ported-with-cite | `MotionInterpreter.cs:2697` |
| apply_interpreted_movement (0x00528600) | ported-with-cite | `MotionInterpreter.cs:2797` |
| DoInterpretedMotion (0x00528360) | ported-with-cite | `MotionInterpreter.cs:2931,2944` |
| StopInterpretedMotion (0x00528470) | ported-with-cite | `MotionInterpreter.cs:3093,3103` |
| Create (0x00528C00) | ported, uncited | `MotionInterpreter.cs:795` — retail's `Create` calls `SetWeenieObject`/`SetPhysicsObject` while `initted==0`, making both no-ops; plain field assignment is behaviorally identical |
| Destroy / ~CMotionInterp (0x00527B40 / 0x00527FF0) | trivial, skipped | GC-obviated (manual `pending_motions` free-list walk superseded by `LinkedList<T>`) |
| HandleEnterWorld (0x00694750) | SYMBOL-ARTIFACT | see methodology note |
| InqStyle (0x00527B10) | SYMBOL-ARTIFACT | see methodology note |
**~85% ported-with-cite** (35/41; ~95% of the 37 real/substantive methods
after excluding 2 symbol-artifacts and 2 GC-obviated dtors). One genuine gap.
### CSequence (28 symbols.json entries)
| Retail method (addr) | acdream status | Cite |
|---|---|---|
| ctor (0x005249F0) | ported, trivial | `CSequence.cs:81` (zero-init, matched by C# field defaults) |
| set_object (0x00524820) | SYMBOL-ARTIFACT, functionally ported | address really resolves to `DBObj::SetDID`; behavioral equivalent is the public `HookObj` field, `CSequence.cs:300` |
| set_velocity / set_omega (0x00524880 / 0x005248A0) | ported-with-cite | `CSequence.cs:236-237` |
| execute_hooks (0x00524830) | ported-with-cite | `CSequence.cs:503` |
| combine_physics / subtract_physics (0x005248C0 / 0x00524900) | ported-with-cite | `CSequence.cs:238-239` |
| multiply_cyclic_animation_fr (0x00524940) | ported-with-cite | `CSequence.cs:249` |
| get_curr_animframe (0x00524970) | ported-with-cite | `CSequence.cs:266` (independently read) |
| set_placement_frame (0x005249B0) | ported-with-cite | `CSequence.cs:258` |
| get_curr_frame_number (0x005249D0) | ported-with-cite | `CSequence.cs:274` |
| apply_physics (0x00524AB0) | ported-with-cite | `CSequence.cs:285` (independently read) |
| apricot (0x00524B40) | ported-with-cite | `CSequence.cs:219` (retail's own PDB-verified name, kept verbatim) |
| has_anims (0x00524BD0) | ported-with-cite | `CSequence.cs:90` |
| remove_link_animations (0x00524BE0) | ported-with-cite | `CSequence.cs:187` |
| remove_all_link_animations (0x00524CA0) | ported-with-cite | `CSequence.cs:207` |
| clear_physics / clear_animations (0x00524D50 / 0x00524DC0) | ported-with-cite | `CSequence.cs:149,140` |
| remove_cyclic_anims (0x00524E40) | ported-with-cite | `CSequence.cs:163` |
| **pack_size / Pack / UnPack** (0x00524F20 / 0x00525020 / 0x005259D0) | **UNPORTED, dead-code-verified** | zero external callers anywhere in the retail decomp; correctly not ported |
| append_animation (0x00525510) | ported-with-cite | `CSequence.cs:109` (independently read) |
| clear (0x005255B0) | ported-with-cite | `CSequence.cs:130` |
| update_internal (0x005255D0) | ported-with-cite | `CSequence.cs:332` (independently read in full — the iterative frame-crossing loop, no safety cap, matches retail exactly) |
| advance_to_next_animation (0x005252B0) | ported-with-cite | `CSequence.cs:435` (independently read) |
| update (0x00525B80) | ported-with-cite | `CSequence.cs:307` |
| ~CSequence (0x00524A30) | trivial, skipped | GC-obviated |
**~79% ported-with-cite** (22/28; ~96% of substantive methods). Cleanest of
the four classes — everything that drives frame playback, hook dispatch, or
physics accumulation is ported and cited. The two known field-representation
divergences (`double` vs x87 `long double` frame_number; `LinkedList<T>` vs
intrusive `DLList`) are already register rows AD-33/AD-34. **No concerning
gap.**
### MotionTableManager (17 symbols.json entries)
| Retail method (addr) | acdream status | Cite |
|---|---|---|
| initialize_state (0x0051C030) | ported-with-cite | `MotionTableManager.cs:353` |
| AnimationDone (0x0051BCE0) | ported-with-cite | `MotionTableManager.cs:290` (independently read) |
| CheckForCompletedMotions (0x0051BE00) | ported-with-cite | `MotionTableManager.cs:322` |
| UseTime (0x0051BFD0) | ported-with-cite | `MotionTableManager.cs:342` |
| HandleEnterWorld / RemoveLinkAnimations (both 0x0051BDD0) | ported-with-cite / SYMBOL-ARTIFACT-duplicate | `MotionTableManager.cs:373` — pseudo-C shows only one body at this address |
| HandleExitWorld (0x0051BDA0) | ported-with-cite | `MotionTableManager.cs:385` |
| SetPhysicsObject (0x0051BBC0) | deliberately-absent-with-reason | `MotionTableManager.cs:20-22` (file header: "C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target as an injectable seam") |
| Create (0x0051BC50) | ported-with-cite | `MotionTableManager.cs:129,138` |
| GetMotionTableID (0x0051BC10) | unported, verified low-risk | retail's only caller (`CPartArray::SetMotionTableID @ 0x005186e0`) uses it purely as a dirty-check before destroying+reconstructing the whole manager; acdream gets the same capability by constructing a fresh `AnimationSequencer`/`MotionTableManager` (`AnimationSequencer.cs:287`) |
| PerformMovement (0x0051C0B0) | ported-with-cite | `MotionTableManager.cs:409` (independently read in full) |
| SetMotionTableID (0x0051BBD0) | unported, verified low-risk | its only caller in the entire retail client is its own `Create` factory (single call site, pc:290526) |
| truncate_animation_list (0x0051BCA0) | ported-with-cite | `MotionTableManager.cs:259` |
| Destroy / ~MotionTableManager | trivial, skipped | GC-obviated |
| remove_redundant_links (0x0051BF20) | ported-with-cite | `MotionTableManager.cs:191` (independently read in full — byte-for-byte match including the `0xb0000000`/`0x70000000` block masks) |
| add_to_queue (0x0051BFE0) | ported-with-cite | `MotionTableManager.cs:166` |
**~65% raw ported-with-cite** (11/17), plus 1 deliberately-absent and 2
unported-but-verified-zero-risk (both `MotionTableID` accessors — retail
itself only "changes" a motion table by destroy+recreate at the
`CPartArray` layer, exactly matching acdream's architecture). **No
concerning gap.**
### CMotionTable (17 symbols.json entries)
| Retail method (addr) | acdream status | Cite |
|---|---|---|
| ctor (0x004F94E0) | ported, uncited | `CMotionTable.cs:64` |
| Pack / UnPack (0x00523180 / 0x005238C0) | **UNPORTED, dead-code-verified** | zero external callers, same dead `PackObj` family as `CSequence` |
| Destroy / ~CMotionTable | trivial, skipped | GC-obviated (`cycles`/`modifiers`/`links` hash tables → `Dictionary<>`) |
| GetDBOType (0x005268A0) | N/A, architecturally superseded | retail RTTI-style type tag; acdream's typed `Dats.Get<MotionTable>()` generic accessor makes it unnecessary |
| Allocator / Allocate (both 0x004F96E0) | SYMBOL-ARTIFACT-duplicate / trivial | `new CMotionTable(table)` supersedes the placement-new+construct factory directly |
| SetDefaultState (0x005230A0) | ported-with-cite | `CMotionTable.cs:605` (independently read) |
| DoObjectMotion / StopObjectMotion / StopObjectCompletely (0x00523E90/0x00523EC0/0x00523ED0) | ported-with-cite | `CMotionTable.cs:635,640,652` |
| re_modify (0x005222E0) | ported-with-cite | `CMotionTable.cs:528` |
| is_allowed (0x005226C0) | ported-with-cite | `CMotionTable.cs:172` |
| get_link (0x00522710) | ported-with-cite | `CMotionTable.cs:201` (independently read — the reversed-key branch, field-validated per its own doc comment) |
| GetObjectSequence (0x00522860) | ported-with-cite | `CMotionTable.cs:255` — independently read in full; the single highest-stakes function in this whole sweep (branch-heavy style/cycle/action/modifier dispatcher), ported branch-for-branch with inline citations, including three explicitly-preserved retail quirks (A4-#1 double-hop tick counting never double-charging the base cycle; A4-#2 silent no-op in `ChangeCycleSpeed` when old speed ~0 but new speed isn't; A4-#5 `ReModify`'s lockstep-snapshot termination bound) |
| StopSequenceMotion (0x00522FC0) | ported-with-cite | `CMotionTable.cs:559` |
Bonus (retail free functions, not `CMotionTable::` members, but ported+cited
in the same file): `same_sign``SameSign` (`:77`), `change_cycle_speed`
`ChangeCycleSpeed` (`:88`), `add_motion``AddMotion` (`:116`),
`combine_motion``CombineMotion` (`:143`), `subtract_motion``SubtractMotion`
(`:156`) — all independently read.
**~53% raw ported-with-cite** (9/17), but **100% of the 9 substantive
motion-selection methods** — every method that isn't Pack/UnPack/RTTI/memory
management is ported and cited. **No concerning gap.**
### Bottom line across all four classes
103 total symbols.json entries examined: ~77 ported-with-cite, 4
symbol-artifacts (not real distinct methods), ~9 trivial/GC-obviated, 1
architecturally superseded, 1 ported-but-uncited (`CMotionInterp.Create`),
and 8 genuinely unported — of which 7 are verified dead code in the retail
client itself (the `Pack`/`UnPack`/`pack_size`/`GetMotionTableID`/
`SetMotionTableID` family, confirmed via call-site tracing). **The single
genuine, unexplained, feel-visible-risk gap across all four classes is
`CMotionInterp::get_adjusted_max_speed` (0x00527D00)** — the only unported
method sitting on a hot per-tick gameplay path (dead-reckoning catch-up
speed clamp) whose retail selection condition could not be resolved from
static analysis alone. All four files hold up as genuinely faithful,
well-cited retail ports; this sweep found no evidence of silently-diverged
gameplay logic in any of the four classes' core responsibilities.
## 2. Feel-visible flow verdicts
This section was independently traced by the lead audit against the primary
source (the extensively retail-cited C# in `CMotionTable.cs`, `CSequence.cs`,
`MotionState.cs`, `AnimationSequencer.cs`, `MotionInterpreter.cs` — most
methods in these five files quote the exact decompiled C body in a doc
comment, so this section cites the acdream file:line as primary evidence
rather than re-deriving from the 1.4M-line decomp text directly; the
background method-coverage-sweep and flow-tracing agents' independent
findings are merged in below where they add or contest something). A
striking finding up front: several of the 7 flows below turned out to have
**verdict parity for a decisive reason that ISN'T "acdream ported it
correctly"** — retail itself doesn't do the fancier thing the flow's framing
implied. That distinction matters for a retail-faithful project: it means
there is nothing to build, not merely nothing left to fix.
**1. Stance-change transition animations — PARITY.**
`CMotionTable.GetObjectSequence` Branch 1 (`src/AcDream.Core/Physics/Motion/CMotionTable.cs:279-336`,
citing retail `GetObjectSequence @ 0x00522860`) is a full style-change
dispatcher: it computes an exit link from the current substate to the
current style's default substate, a direct link from the current style's
default substate to the target style's default substate, and — when no
direct link exists — a double-hop through the table's `DefaultStyle` (lines
308-314), then plays exit-link → hop1 → hop2 → new-cycle in sequence
(`AddMotion` calls at 318-321) before installing the new style/substate.
This is retail's genuine weapon-draw/style-change link mechanism, not a
simplified instant cut. `AnimationSequencer.SetCycle`
(`src/AcDream.Core/Physics/AnimationSequencer.cs:390-391`) drives style
changes through exactly this path before dispatching the target motion.
**2. Landing after jump/fall (soft vs hard landing) — PARITY, and the "gap"
doesn't exist in retail.** `MotionInterpreter.HitGround`
(`src/AcDream.Core/Physics/MotionInterpreter.cs:2425-2443`) quotes retail's
`CMotionInterp::HitGround @ 0x00528ac0` FULL BODY: strip link animations,
then re-apply the PRESERVED pre-fall interpreted forward command (walk/run/
ready) — there is no velocity, fall-distance, or fall-duration branch
anywhere in that function. Retail's `Falling` SubState
(`MotionInterpreter.cs:56-63`) is one airborne cycle regardless of how far
the body fell; landing is simply "the Falling→X link fires through the same
`GetObjectSequence` Branch 2 cycle-to-cycle mechanism verified in item 6."
There is no severity-based "hard landing" animation to select in retail's
own Humanoid MotionTable, so this was never a divergence to close.
**3. In-place turn cycles vs omega-driven turning — PARITY (already shipped
under R6; not re-audited here per task scope, confirmed only that the two
things are the SAME mechanism, not competing ones).** `TurnRight`/`TurnLeft`
(`0x6500000D`/`0x6500000E`) carry the `0x40000000` cycle-class bit
(`0x65000000 & 0x40000000 != 0`), so they ARE genuine `CMotionTable` cycles
with their own authored `Anims` (the visual leg-crossing/pivot animation)
AND their own authored `Omega` (R6's pinned finding: `omega.Z = -1.5`
rad/s ≈ -86°/s from the installed Humanoid table, not a synthetic 90°
formula). `CMotionTable.AddMotion`
(`src/AcDream.Core/Physics/Motion/CMotionTable.cs:116-134`) writes both the
anim frames and the omega from the SAME `MotionData` record onto the
sequence in one call; `CSequence.ApplyPhysics` rotates the Frame by that
omega every frame the turn cycle plays. There was never a separate
"visual cycle vs physical rotation" question to resolve — one MotionData
record drives both.
**4. Walk↔run mid-stride transitions — PARITY (same Branch-2 machinery as
item 1, one level down).** Walk and Run are both cycle-class substates
within `NonCombat`/combat styles, so crossing the walk/run threshold or
toggling the Run hold-key is a same-style cycle-to-cycle request through
`GetObjectSequence` Branch 2 (`CMotionTable.cs:341-423`): it looks up a
direct link between the two substates via `GetLink`, falls back to a
style-default double-hop if none exists (lines 378-383), and has a
same-substate "fast re-speed" path (lines 358-367) for a pure speed change
within the SAME substate (e.g. accelerating while already running) that
rescales the cyclic framerate and physics in place rather than re-triggering
a full transition. This is retail's genuine walk-to-run link/blend
mechanism, not an instant swap.
**5. Backward/strafe cycle selection — PARITY, and again the "gap" doesn't
exist in retail.** `AnimationSequencer.SetCycle`
(`src/AcDream.Core/Physics/AnimationSequencer.cs:344-348, 367-381`) states
plainly, citing ACE's `MotionInterp.cs:394-428` as cross-check: "the AC
MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward. These
are played as their right-side/forward equivalents with a negated
framerate so the animation runs in reverse." This is a retail asset-content
fact, not an acdream simplification — there is no distinct backward-walk or
strafe-left animation to select in the first place; retail itself reverses
the forward/right cycle. acdream's remap (WalkBackward → WalkForward at
-0.65×speed, SideStepLeft → SideStepRight at -1×speed) matches this exactly
at both the `AnimationSequencer` boundary (local-player raw input) and the
`MotionInterpreter.adjust_motion` boundary (wire-level, R3-cited) — see
section 1's method sweep for whether both call sites are still needed or
one is now dead code.
**6. Link-animation traversal system — PARITY, and it is the single most
load-bearing finding of this audit.** `CMotionTable.GetLink`
(`CMotionTable.cs:190-241`, retail `get_link @ 0x00522710`) is a genuine,
general-purpose `(fromStyle, fromSubstate, toSubstate)` link lookup over the
DAT-authored `Links` dictionary — not a hardcoded Ready/Walk/Run subset. It
handles the forward direction, a reversed-key direction (used when a speed
sign flip means "the link is authored the other way," e.g. the Ready↔
WalkBackward case the doc comment says was field-validated fixing a
"left leg twitches" glitch), and a style-level catch-all fallback. Every one
of `GetObjectSequence`'s four branches (style-change, cycle, action,
modifier) calls it and composes the result into 1-3 chained `AddMotion`
calls (exit link, direct/hop1, hop2) before the target cycle, exactly
matching retail's own double-hop-via-`DefaultStyle` fallback for style
changes with no direct link, and an out-hop/action-link/return-hop triple
for action-class motions with no direct link to the target (`CMotionTable.cs:428-478`,
with the load-bearing `#A4-1` tick-count citation: "never the base cycle,
never double-counted (ACE's bug, not retail's)" — i.e. acdream's tick
accounting is MORE correct than the reference ACE port here, not less).
This resolves the audit's biggest open question going in: acdream does not
skip genuine style-to-style links (drawing a weapon, sheathing, sitting
down) in favor of a hardcoded locomotion-only subset.
**7. Interrupted-animation behavior — PARITY at the queue-mechanics level;
one narrower residual question outside this file set.** A dedicated
flow-tracing sub-agent (independent pass, cross-checked against ACE) closed
most of the uncertainty this item started with. Retail
`MotionTableManager::RemoveRedundantLinks` (`0x0051bf20`) explicitly only
collapses cycle-class-not-modifier or style-class queue tails — the
modifier/action-class branch is "neither branch taken" (confirmed directly
in `CMotionTable.cs`'s ported `RemoveRedundantLinks`, see section 1): action-class
one-shots (attacks, casts) are **never truncated** by this mechanism and
always run their tick-countdown to natural completion. Separately, retail
`CPhysicsObj::interrupt_current_movement` (`0x005101f0`) is called
unconditionally from `jump()` and cancels an in-flight `MoveToManager`
transition — a wholly different mechanism from the action queue, not a
"cancel this attack" primitive. acdream's `MotionTableManager.RemoveRedundantLinks`
(`src/AcDream.Core/Physics/Motion/MotionTableManager.cs:191-248`) is a
byte-for-byte match including the identical `0xb0000000`/`0x70000000` block
masks and the same fallthrough, and the `InterruptCurrentMovement` seam
(`MotionInterpreter.cs:658`) is fully wired in PRODUCTION — not a stub — to
real `MoveToManager.CancelMoveTo(WeenieError.ActionCancelled)` in both
`src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:152-154`
(remote) and `src/AcDream.App/Input/PlayerModeController.cs:354-362` (local
player), plus 4 call sites in `StickyManager.cs`. **Net: attack/cast
animations are uninterruptible by movement/jump input in BOTH clients —
movement just queues behind them; jump only ever cancels an in-flight
move-to, never the action queue.** The one thing this audit still did not
verify: whether higher-level combat/magic-casting code (entirely outside
`MotionInterpreter`/`MotionTableManager`, not read for this audit) layers
its own ADDITIONAL "can't move while casting" rule on top of this queue
mechanism — that would live in the combat/magic subsystem and needs a
separate targeted read, not a live capture.
**Additional residual surfaced by the flow-tracing pass, item 1 (stance
change):** the mechanism (`GetObjectSequence` Branch 1 + `GetLink`) is
confirmed parity, but whether acdream's higher-level default-combat-mode
selection (`CombatInputPlanner.GetDefaultCombatModeDecision`, not read in
this audit) picks the exact same weapon-style-to-CombatMode mapping as
retail's `ClientCombatSystem::GetDefaultCombatMode` (`0x0056B310`) in every
edge case was NOT traced — flagged as a small untraced item, not a
confirmed divergence.
**Ranked feel-impact of these 7, most to least:** all 7 came back parity —
an unusual, striking result for a from-scratch port of this scope. Ranking
by residual RISK rather than impact (i.e., where a future capture is most
likely to still surface a surprise, since several parity claims rest partly
on DAT-content assumptions rather than pure code): (1) item 7's untraced
combat/casting-layer interrupt rule and item 1's untraced default-combat-mode
mapping are the two loose threads worth a follow-up read (not a live
capture); (2) items 3/4/6 (turn cycles, walk-run link, general link
traversal) are the most solid — confirmed via both the acdream code AND an
independent cross-check against ACE's own C# `MotionTable.cs` port, which
shows the same double-hop structure; (3) items 2/5 (landing, backward/strafe)
are effectively closed — in both cases the "gap" the flow's framing
hypothesized doesn't exist in retail itself (one universal landing
transition; forward/right cycles reverse-played rather than distinct
backward/left clips), corroborated for item 5 by holtburger's wire-level
`MovementCommand` enum showing `WalkBackwards`/`TurnLeft`/`SidestepLeft` as
distinct wire ids (confirming the reversal is a client animation-layer
transform, not a wire-format absence). Net: of the 7 flows the task asked to
trace, all are parity; the two follow-up threads (combat-layer cast
interrupt, default-combat-mode mapping) are outside the `MotionInterpreter`/
`CMotionTable`/`CSequence` file set this audit focused on and are noted for
a future combat/magic-scoped audit, not scheduled as animation fixes.
## 3. TS-50 / TS-51 current scoping (verified against current code, 2026-07-30)
Both rows are precisely as described in the register — re-reading the actual
code confirms rather than narrows either row. No promotion to a fix is
recommended; both remain the correct classification (deliberate ordering
adaptation with a bounded, named residual), not silent regressions.
**TS-50 — which hook types still deliver late.** Read
`src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs:37-86` (`Capture`):
for every hook produced by a sequence advance, the queue tests
`hooks[i] is AnimationDoneHook` (line 75) and, ONLY for that one hook type,
synchronously calls `sequencer.Manager.AnimationDone(success: true)` at
capture time — i.e. inside the same call that advanced the sequence, matching
retail `CPhysicsObj::process_hooks @ 0x00511550` timing exactly (semantic
motion completion, Target/Movement/PartArray/Position manager tail all see it
in the same quantum). EVERY OTHER hook type reaching this queue (from the
`DatReaderWriter.Types.AnimationHook` hierarchy routed through
`AnimationHookRouter` to `AudioHookSink`, `ParticleHookSink`,
`TranslucencyHookSink`, `LightingHookSink` — i.e. sound playback, particle
creation including `RetailCreateBlockingParticleHook`, translucency-fade
starts, light attach, and `PhysicsScriptHook`/CallPES-adjacent triggers) is
unconditionally appended to `_entries` (line 82-85) and only fires later, in
`Drain()` (lines 88-125), which is called exactly once per render/update
frame from `LiveEffectFrameController.Tick` at
`src/AcDream.App/Update/LiveObjectFrameController.cs:107-126` — AFTER every
live entity's root/part/equipped-child pose has been published for that
frame (the comment at `LiveObjectFrameController.cs:109-113` names this
explicitly: "acdream currently keeps non-AnimationDone hooks at this
deferred shared boundary under TS-50"). So the answer to "which hook types
still deliver late": **all of them except semantic AnimationDone** — sound,
particle, light, translucency, and CallPES/script-chain hooks can be up to
one render frame later than retail's per-object `process_hooks` moment.
Feel-visible risk is concentrated in **CallPES** (a hook that triggers a
PhysicsScript chain, e.g. spawning a follow-up effect keyed to a specific
animation frame) and blocking-particle creation tied to an attack's exact
swing frame — a one-frame-late particle spawn on a fast weapon swing is the
kind of thing a careful side-by-side viewer could notice, though nobody has
filed a symptom against it yet. Audio/light/translucency lateness is far
less likely to be perceptible at typical frame rates.
**TS-51 — per-render-frame vs per-quantum tails.** Confirmed at
`LiveObjectFrameController.cs:107-126`: `LiveEffectFrameController.Tick(float
deltaSeconds)` advances `_particles.Tick(deltaSeconds)` and
`_scripts.Tick(_scriptTime.CurrentScriptTime)` exactly once per call, and
this controller is driven once per render/update frame (not once per
admitted 30 Hz physics quantum per live object). Retail's
`CPhysicsObj::UpdateObjectInternal @ 0x005156B0` advances each ordinary
object's own ParticleManager then ScriptManager inside EVERY admitted
quantum for THAT object, and `animate_static_object @ 0x00513DF0` uses a
different order (Script → Particle → hooks) for the static-object workset.
acdream's shared tail is Particle → Script after static hook capture,
uniformly, once per render frame regardless of how many physics quanta a
given object admitted that frame. Practical effect: on a catch-up frame
(object advances several quanta at once, e.g. after a stall), the object's
root/pose advances through all of them but its particle/script tail only
advances once — an emitter that should have spawned N times in that
interval spawns once with N ticks' worth of `deltaSeconds`, and static
default-script/particle ordering runs in the opposite sequence from
`animate_static_object`. This is a real feel-visible risk specifically for
dense fast-tick emitters (rapid-fire spell effects, chain particle bursts)
but is architecturally deep to fix (needs incarnation-bound per-object
particle/script manager instances, which the register row itself names as
the retirement condition) — not a quick promotion candidate.
**Verdict:** neither row's scope has changed since the register was last
written; both remain accurately described. Of the two, TS-50's CallPES
lateness is the more plausible candidate for a future promotion (narrower
blast radius — "make CallPES and blocking-particle hooks fire at capture
time like AnimationDone, keep the rest deferred" is a bounded change),
whereas TS-51 needs the larger incarnation-bound-manager refactor the row
already flags.
## 4. Issue #64 (local pickup animation) reassessment
**Original hypothesis (filed 2026-05-14, pre-R3/R4/R6):** `OnLiveMotionUpdated`
filters local-player self-echoes wholesale, so ACE's server-authored
`Motion(MotionCommand.Pickup)` broadcast (via
`Player_Inventory.AddPickupChainToMoveToChain`
`EnqueueBroadcastMotion(motion)`) never reaches the local player's animation
path. That exact function (`OnLiveMotionUpdated`) no longer exists in the
current tree (`git grep` for it returns nothing) — the inbound motion path
has been rewritten at least twice since (R4-V5's local/remote unification,
then the J-slice Runtime extraction), so the original hypothesis needs to be
re-evaluated against the CURRENT architecture, not assumed stale or assumed
still-broken.
**Current architecture, traced end to end:**
1. `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:216-241`
(`OnMotion`) — the first gate is a TIMESTAMP staleness check
(`_authorityGate.TryAcceptMotion`), unrelated to self-echo.
2. Lines 224 and 243-259 — the REAL self-echo gate, `R4-V5 (pin P1)`: `bool
retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;`
This drops an entire UpdateMotion packet ONLY when it targets the local
player's guid AND the packet's wire-level `IsAutonomous` byte is set — the
comment cites retail `CPhysics::SetObjectMovement`'s autonomous gate
(`0x00509690 @0050972e`, raw 271370-271431) and explains WHY: ACE reflects
the client's own outbound `MoveToState` back to the sender with
`IsAutonomous=1` hardcoded (`MovementData.cs:162`,
`Player_Networking.cs:365` in the ACE reference), so this gate exists
specifically to drop THAT reflection, not every inbound packet addressed
to the player.
3. If the packet survives, the local-player branch at lines 450-509 routes
through the SAME `RemoteInboundMotionDispatcher.Apply` used for remotes
(`src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs`), which for a
`MovementType == 0` packet calls `motion.MoveToInterpretedState(interpreted,
animationSink)` (`RemoteInboundMotionDispatcher.cs:108-112`).
4. `MotionInterpreter.MoveToInterpretedState`
(`src/AcDream.Core/Physics/MotionInterpreter.cs:2697-2743`) replays each
entry in `ims.Actions` (the Commands[] one-shot list, populated by
`InboundInterpretedMotionFactory.Create` from the wire's `Commands`
field — `src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63`)
through `DispatchInterpretedMotion`, with exactly ONE local-player-specific
filter at line 2735: `if (IsLocalPlayer && a.Autonomous) continue;` — this
is scoped to the PER-ACTION autonomous bit inside the Commands[] entry
(`MotionItem.PackedSequence & 0x8000`), not a blanket "is this the local
player" drop.
**Current best hypothesis:** ACE's Pickup broadcast is server-initiated (built
via `EnqueueBroadcastMotion`, not a reflected client `MoveToState`), so both
gates that could drop it — the packet-level `IsAutonomous` check (step 2) and
the per-action `Autonomous` bit check (step 4) — should read `false` for it,
same as for any other server-authored one-shot action a remote observer would
see. If that reading of ACE's flag values is correct, **the R4-V5 local/remote
unification (which post-dates #64's filing by roughly two months) likely
fixed this issue as an architectural side effect**, without anyone
specifically targeting #64. This is a hypothesis, not a confirmed fix — this
audit is report-only and did not launch the client or trigger a live pickup.
**Recommended next step (not performed here):** re-test #64 live with
`ACDREAM_DUMP_MOTION=1` set, trigger a close-range pickup as `+Acdream`, and
check the log for a `UM guid=<player> ... cmd=...` line whose resolved
command carries the Pickup action bit, followed by the sequencer actually
playing the one-shot cycle. If it still fails, the next diagnostic is to
confirm whether ACE's Pickup broadcast really sets `Autonomous=false` at
both the packet and per-action level (a WireMCP capture on the loopback
`UpdateMotion (0xF74D)` packet during a pickup would settle this instead of
reading ACE source), since a wrong assumption there is the one way this
hypothesis could be wrong.
## 5. Emote/action surface (AD-57) gap sizing
**What retail would play:** an animated social emote (retail's action-class
MotionCommand — the `0x10000000` bit family, e.g. a wave/point/bow/salute
cycle, as opposed to `/e <text>` roleplay chat text) is queued through the
SAME `CMotionInterp` action-list machinery already ported: `AddAction` onto
`RawMotionState`/`InterpretedMotionState`, packed onto the wire by
`RawMotionState::Pack` (retail `0x0051ed10`) as `num_actions` +
per-action pairs, broadcast to observers as a Commands[] entry on
`UpdateMotion`, and resolved into a `CMotionTable` action-class cycle via the
same `CMotionTable::GetObjectSequence` path used for locomotion cycles.
**What acdream has, precisely:** per section 4's trace, the RECEIVING half of
this pipeline is fully wired and (per the current-best-hypothesis above)
likely already plays a server-broadcast one-shot action correctly for both
local and remote observers — `InboundInterpretedMotionFactory` parses
Commands[] into `InboundMotionAction`s, `MotionInterpreter.MoveToInterpretedState`
replays them through `DispatchInterpretedMotion` into the same
`CMotionTable`/`CSequence` cycle-selection path as any other motion. The
SENDING half — the local player's own input constructing and enqueueing an
autonomous action, e.g. from a `/wave`-style command — has no production call
site: `grep -r "\.AddAction(" src/AcDream.App` returns nothing outside test
code (`RawMotionState.AddAction`/`InterpretedMotionState.AddAction` are
exercised only by unit tests, confirmed during this audit). The chat command
catalog (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs:144-150,232-234`)
has `/emote` and `/emotes`, but these are documented as the roleplay TEXT
emote (`/e <text>` — chat-log output), not an animated action — there is no
slash command or UI affordance in the catalog for a genuine visual emote.
**Gap size:** this is a clean, well-isolated feature gap, not a divergence
of any shipped behavior — the register row (AD-57, re-argued 2026-07-30) is
correct that "every currently-shipped movement packet matches retail byte-shape;
the gap only manifests when emote-class autonomous actions are implemented."
The work to close it is bounded and almost entirely additive: (a) a
retail-sourced list of which MotionCommand action IDs are genuine social
emotes and what UI/command surface retail exposes them through (character
menu right-click? a `/motion` or numbered emote command? — this needs a
named-retail grep, not guessed), (b) a client input path that calls
`MotionInterpreter.DoMotion`/`AddAction` with the right action ID and
`Autonomous=true`, and (c) confirming the existing outbound packer already
emits it correctly (it should, since `RawMotionStatePacker` already handles
the `Actions` list per AD-57's own text). No architecture changes are
required — this is squarely a "wire up an existing, tested machine" gap, sized
small-to-medium (one research pass to find the retail command surface, one
implementation pass to wire input → `AddAction`).
## 6. Ranked gap catalog + recommended fix order
**Headline result:** this audit set out to find where acdream's animation
system diverges from retail and found the system in unusually good shape.
Two independent research passes (a symbol-by-symbol method-coverage sweep
and a 7-flow feel-visible trace) plus the lead auditor's own full read of
the four core files converged on the same conclusion: `MotionInterpreter`
(`CMotionInterp`), `CSequence`, `CMotionTable`, and `MotionTableManager` are
faithful, extensively retail-cited ports, and all 7 traced feel-visible
flows came back parity — three of them (landing severity, backward/strafe
cycles, in-place-turn-cycle-vs-omega) because the richer retail behavior the
flow's framing assumed doesn't actually exist in retail either. The gap
catalog below is therefore short and each entry is genuinely small.
**Ranked by feel-impact, most to least:**
1. **AD-57 — animated emote authoring gap (feel-visible, bounded scope).**
The RECEIVING half of retail's action-class one-shot animation system
(server-broadcast one-shots like Pickup, and presumably other players'
emotes) is fully wired and plays correctly through the same
`GetObjectSequence` Branch 3 machinery as any other motion (see section
4/5). The SENDING half — the local player triggering their OWN animated
emote (retail's `/wave`-equivalent) — has no production call site;
`RawMotionState.AddAction`/`InterpretedMotionState.AddAction` are
exercised only by unit tests. This is the most user-visible gap in the
catalog (a whole category of retail behavior — animated social
gestures — is simply absent from the client), but it is squarely a
"wire up an existing, tested machine" gap: no architecture change, no
new port, just (a) a named-retail grep for which MotionCommand action
IDs are genuine emotes and what UI surface retail exposes them through,
(b) an input path that calls `DoMotion`/`AddAction` with the right
action ID and `Autonomous=true`, (c) confirming the existing
`RawMotionStatePacker` emits it correctly (should already, per AD-57).
2. **TS-50 residual — CallPES and blocking-particle hooks up to one render
frame late (already known, narrow promotion candidate).** Verified
against current code (section 3): only the semantic `AnimationDoneHook`
fires at capture time; every other hook type (CallPES/script-chain
triggers, particle creation including blocking particles, sound, light,
translucency-fade starts) is deferred to `AnimationHookFrameQueue.Drain()`,
called once per render frame after all entities' poses publish. Most
feel-visible on a fast weapon swing where a blocking-particle effect is
keyed to an exact frame. Bounded fix: make CallPES and blocking-particle
hooks fire at capture time like `AnimationDoneHook`, keep the rest
deferred — narrower than the full TS-51 refactor below.
3. **Issue #64 — local pickup animation not rendering (likely already
fixed, zero-cost to verify).** Section 4's trace shows the R4-V5 local/
remote unification (which post-dates #64's filing) architecturally
closed the exact mechanism the original hypothesis blamed: the
packet-level and per-action autonomous-echo gates are now scoped
precisely enough that a server-authored one-shot (non-autonomous, by
construction) should reach the local player's `DispatchInterpretedMotion`
exactly like it does for remotes. This needs a 2-minute live re-test
(`ACDREAM_DUMP_MOTION=1`, trigger a close-range pickup), not an
engineering investment — likely already closed as a side effect of
unrelated work and just needs its ISSUES.md status updated.
4. **`CMotionInterp::get_adjusted_max_speed` unported (narrow, needs a
cdb read before it's even confirmed live).** The one genuine
unexplained gap from the method-coverage sweep (section 1): retail's
dead-reckoning catch-up path chooses between `get_max_speed` and this
sibling via a static toggle (`InterpolationManager::fUseAdjustedSpeed_`)
whose default this audit could not resolve from static analysis alone.
acdream always uses `GetMaxSpeed()` for remote catch-up. If the toggle
defaults to the adjusted variant in retail, acdream's remote
dead-reckoning catch-up speed could be systematically using the wrong of
two very similar formulas — narrow blast radius (one clamp value in one
catch-up path), plausible feel effect (slightly different snap-back
speed on remotes catching up after a network stall). Per the project's
own retail-debugger toolchain, this is a cdb-read-the-static-value
question, not a guess-and-ship one.
5. **TS-51 residual — particle/script tails once per render frame instead
of once per admitted physics quantum (already known, larger refactor).**
Verified against current code (section 3): on a catch-up frame where an
object advances several 30 Hz quanta at once, its particle/script tail
only advances once with the accumulated `deltaSeconds`, and static
default-script/particle ordering runs Particle→Script instead of
retail's Script→Particle→hooks. Most feel-visible for dense fast-tick
emitters (rapid spell-effect chains). The register row itself names the
retirement condition (incarnation-bound per-object particle/script
manager instances) — this is real architectural work, not a quick
promotion, and should stay queued behind the current M4 feature-work
order rather than jumping the line for this audit.
6. **Two untraced items outside this audit's file scope (flagged, not
confirmed divergences).** The flow-tracing pass surfaced two loose
threads it didn't have scope to chase: (a) whether combat/magic-casting
code (entirely outside `MotionInterpreter`/`MotionTableManager`) layers
its own additional "can't move while casting" rule on top of the
confirmed-parity action-queue mechanism; (b) whether
`CombatInputPlanner.GetDefaultCombatModeDecision` picks the same
weapon-style-to-CombatMode mapping as retail's
`ClientCombatSystem::GetDefaultCombatMode` (`0x0056B310`) in every edge
case. Both are plausible-but-unconfirmed and belong to a combat/magic-
scoped audit, not this animation-scoped one — recommended as a future
audit topic, not a fix.
**Recommended fix order** (cheapest/highest-confidence first): (1) re-test
#64 live and close the issue if confirmed — essentially free; (2) research
+ wire the emote-sending path (AD-57) — bounded, additive, no architecture
change, the single most user-visible improvement available; (3) narrow
TS-50's promotion to cover CallPES + blocking-particle hooks specifically;
(4) cdb-verify `fUseAdjustedSpeed_`'s retail default before deciding whether
`get_adjusted_max_speed` needs porting at all; (5) queue the full TS-51
incarnation-bound-manager refactor behind current M4 feature work, since it
is real architectural investment rather than a bounded fix; (6) file a
follow-up combat/magic-scoped audit for the two untraced items rather than
guessing at their status here.
This audit made no code changes and files no fixes directly — items 1-4
above are small enough that the user may want to fold them into the next
convenient M4 work session; item 5 should go through the normal roadmap
process (a new phase/slice, not a drive-by fix) given its architectural
size; item 6 needs its own investigation before any fix is proposed.

View file

@ -1,145 +0,0 @@
# #167 ConstraintManager leash — constants recovered + arming flow (Campaign P P5)
**2026-07-30.** Both #167 blockers are now research-solved; only the port
remains. No cdb session was needed: the two "unknown x87 constants" were
recovered by decoding the raw machine code of the matching binary
(`C:\Users\erikn\Downloads\acclient.exe`, v11.4186, PDB-paired — verified
GUID match per the retail debugger toolchain doc).
## 1. The getters, byte-decoded (FACT)
`CPhysicsObj::GetStartConstraintDistance @ 0x0050ebc0` and
`GetMaxConstraintDistance @ 0x0050ec10` are FPU-return getters whose
`fld` operands Binary Ninja elided (the pseudo-C shows a bare
`this->m_position;`). Raw bytes (file offset 0x10ebc0/0x10ec10):
```
3b 0d 58 3d 84 00 cmp ecx, [0x00843d58] ; this == player_object?
75 1d jnz non_player
8b 41 4c mov eax, [ecx+0x4c] ; m_position.objcell_id
25 ff ff 00 00 and eax, 0xFFFF
3d 00 01 00 00 cmp eax, 0x100
73 07 jae indoor ; low16 >= 0x100 = EnvCell
d9 05 <rdata> fld dword [outdoor_const]
c3 ret
indoor: d9 05 <..> fld dword [indoor_const]
c3 ret
non_player: ; identical cell test, second constant pair
```
Constant values read from `.rdata`:
| | player outdoor | player indoor | remote outdoor | remote indoor |
|---|---|---|---|---|
| Start (0x007c6abc..c8) | **10.0** | **5.0** | 10.0 | 5.0 |
| Max (0x007c6acc..d8) | **50.0** | **20.0** | 50.0 | 20.0 |
Two consequences (FACT):
1. **The player-vs-remote branch is vestigial** — both sides load
identical values. Effective semantics: `start = outdoor 10 m /
indoor 5 m`, `max = outdoor 50 m / indoor 20 m` (indoor = cell low16
≥ 0x100).
2. **ACE's `GetStartConstraintDistance` is INVERTED**
(`ACE PhysicsObj.cs:620`: outdoor 5 / indoor 10). ACE's max mapping
(outdoor 50 / indoor 20) matches the binary. Do NOT copy ACE's start
mapping. (feedback_acme_oracle / binary-wins rule.)
## 2. The arming flow — `SmartBox::HandleReceivedPosition @ 0x00453fd0` (FACT)
Pseudo-C lines ~92940-93060. After the update-time staleness gates and
`unset_parent`/`SetPlacementFrame` handling:
- **Remote object** (`arg2 != this->player`): call
`MoveOrTeleport(obj, &recvPos, ts, arg5, arg6)`; **only if it returns
nonzero** (the position was NOT hard-teleport-applied), arm the leash
**anchored to the object's own current position**:
`ConstrainTo(obj, &obj->m_position, start, max)` (0x00454254-72).
- **Player, teleport-newer** (`newer_event(TELEPORT_TS, ts)`):
`SmartBox::TeleportPlayer(&recvPos)`, then
`ConstrainTo(player, &recvPos, start, max)` — anchored to the
**received** position — then `set_velocity(player, {0,0,0}, 1)`
(0x0045415f-c0).
- **Player, normal**: `ConstrainTo(player, &recvPos, start, max)`
anchored to the received position; then, if
`cmdinterp->UsePositionFromServer() && arg5`,
`InterpolateTo(&recvPos, -GetAutonomyLevel())` (0x004541c9-422c).
The taper/enforcement side (`ConstraintManager::UseTime` feeding
`adjust_offset`, `IsFullyConstrained = ConstraintDistanceMax * 0.9 <
offset`) is already ported in
`src/AcDream.Core/Physics/Motion/ConstraintManager.cs` (R5-V1,
`docs/research/2026-07-03-r5-managers/`); it has simply never been armed.
## 3. Port shape for P5 (INFERENCE — implementation guidance)
1. Add the four-constant getters (outdoor/indoor by full cell id low16)
at the body/host layer; keep the vestigial player/remote split OUT
(note it in a code comment with this doc as the cite).
2. Arm at acdream's inbound-position equivalents of the three branches:
the remote UpdatePosition acceptance tail (post-`MoveOrTeleport`
routing in the live-entity network update path) and the local
player's accepted-Position path (normal + teleport). Anchor per §2.
3. `PhysicsBody.IsFullyConstrained` (register TS-35 stub) becomes a read
through `PositionManager`/`ConstraintManager`, so
`jump_is_allowed`'s ported gate fires (WeenieError 0x47) while
rubber-banding. TS-35 and #167 retire together, same commit.
4. Conformance tests: constant table incl. the ACE-inversion pin
(outdoor start MUST be 10, not 5); leash-armed jump refusal;
remote-vs-player anchor difference; teleport-branch velocity zero.
## Open questions
None for the constants/flow. Remaining implementation risk is only
where acdream's position-acceptance seams sit today (J6.3 moved
teleport correlation into Runtime — the implementer must find the
current owner rather than trusting older file cites).
## As-ported (Campaign P Slice P5, 2026-07-30)
The implementation risk flagged above resolved to these CURRENT seam owners
(post-J-slices) — recorded here so the next reader doesn't have to re-derive
them:
- **Constants**`src/AcDream.Core/Physics/Motion/ConstraintDistance.cs`.
Keyed purely on the object's own full cell id's low 16 bits (`>= 0x0100` =
indoor); the vestigial player/remote branch from §1 is deliberately not
represented as an API parameter.
- **Remote arm**`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs`,
the inbound `UpdatePosition` handler's remote branch (`update.Guid !=
_playerServerGuid`), immediately after the `remotePlacementRequired`
hard-teleport block returns (that block already covers retail's
`MoveOrTeleport` Branch A / hard-place case; everything reached past it is
"did not hard-place"). One call site covers BOTH player-remote and NPC
remotes — retail's `SmartBox::HandleReceivedPosition` doesn't distinguish
them either, only `GetStart/MaxConstraintDistance`'s now-omitted vestigial
branch did. Anchored to the live `IPhysicsObjHost.Position` (which reads
`RemoteMotion.Body.Position` + the tracked cell id), matching retail's
"anchored to the object's own current position" — since the anchor and
`_host.Position` read are the same value at call time,
`ConstraintManager.ConstrainTo`'s initial offset is always 0 regardless of
whether the routing above just far-snapped or left a near-correction
queued.
- **Remote per-tick taper + `IsFullyConstrained` push** — already wired
pre-P5 for the taper (`RuntimeRemotePhysicsUpdater.Tick`/`TickHidden` call
`PositionManager.AdjustOffset` every tick via the pre-existing R5-V3
sticky/constraint chain); P5 added the `PhysicsBody.IsFullyConstrained =
host.PositionManager.IsFullyConstrained()` push at the same two call
sites, since `MotionInterpreter` only holds a `PhysicsBody` (no host
reference) and needs a live value to read.
- **Local player arm**`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`:
`SetPositionCore` (teleport: `UnConstrain` then re-`ConstrainTo` after the
existing `StopCompletelyAtPhysicsObjectBoundary` velocity zero — composed,
not duplicated) and `CommitPreparedPosition` (mirrors the same pair for
the deferred player-mode-entry commit path); `BlipPosition` (ForcePosition:
`ConstrainTo` only, no teardown — matches `SmartBox::BlipPlayer` surviving
motion/velocity/stick). Anchored to `_body.CellPosition` (the just-applied
received position).
- **Local player per-tick taper + push**`PlayerMovementController.Update`
already called `PositionManager.AdjustOffset` every physics tick pre-P5;
P5 added the `_body.IsFullyConstrained = PositionManager?.IsFullyConstrained()
?? false` push immediately after, at the same chokepoint.
- **TS-35 retirement**`PhysicsBody.IsFullyConstrained` stayed a plain
settable bool (not a computed property) so the ~40 pre-existing direct-set
unit tests keep working; the per-tick pumps above are its single writers
now, matching the project's per-entity single-owner-write pattern.

View file

@ -1,196 +0,0 @@
# The landing bounce family — retail bounce vs ground vs slide (investigation, report-only)
**Date:** 2026-07-30 · **Status:** IMPLEMENTED (same day — see §Implementation)
**Symptoms (user, live gate):** (1) downhill jumps glide instead of bouncing;
(2) flat-ground jumps at speed/height don't bounce; (3) uphill jumps get stuck
in weird animations, flapping and gliding. Speed (#266) and roof slide
(#265's freeze) are fixed and unaffected.
## The retail mechanism (decomp, read end-to-end this session)
Three functions compose the whole behavior:
### 1. The floor-touch dual record (plane handler, 0x0050d100-0x0050d30c)
Touching a floor plane records **two independent facts**:
```c
if (step_down || !(state & CONTACT) || is_valid_walkable(plane))
set_contact_plane(plane) // grounding fact
if (!(state & CONTACT) && !step_down) {
set_collision_normal(plane.N); // collision fact
collided_with_environment = 1;
}
```
A landing (not already in contact, not a step-down probe) is BOTH a contact
AND an environment collision carrying the floor normal. Ordinary walking
(already in contact / step-down glue probes) records only the contact —
that is why walking never bounces.
**acdream's transition already ports this faithfully**
(`TransitionTypes.cs:3410-3415``!oi.Contact && !sp.StepDown`
`SetCollisionNormal` + `CollidedWithEnvironment = true`).
### 2. SetPositionInternal (0x00515330, read fully — VELOCITY-SIGN-FREE)
```
contact = collision_info.contact_plane_valid (no velocity test)
on_walkable = contact && contact_plane.N.z >= floor_z (set_on_walkable → HitGround/LeaveGround)
handle_all_collisions(collision_info, prevContact, prevOnWalkable) ← velocity UNMODIFIED
```
There is **no `Velocity.Z <= 0` landing gate and no velocity zeroing**
anywhere in retail's commit. Contact is a per-frame fact from the
transition's contact plane; the bounce is the velocity reflect; they are
independent and coexist — you can be "landed" this frame AND carry
reflected +Z that lifts you off next frame. That IS the bounce chain.
(Our Core `PhysicsObjUpdate.CommitSetPositionTransition` is already a
faithful port of this function — used by teleport/remote placement, NOT by
the local player's per-tick path.)
### 3. handle_all_collisions (0x00514780) + elasticity
For fsf≤1, should-reflect (NOT(was-walkable AND still-walkable) or the
garbled state-flag override), valid collision normal, and `v·n < 0`:
```
v += -(v·n) · (elasticity + 1) · n // pc:282712
```
`DEFAULT_ELASTICITY = 0.05` (byte constant @0x007c6a7c; ctor writes at
0x005124d3/0x0051d537; `set_elasticity` clamps to [0, 0.1]). So every
landing reverses 5% of the impact's normal component and keeps the full
tangential component:
- **Flat ground at speed/height:** v=(6,0,7) → v'=(6,0,+0.35) — forward
carry plus a visible pop at speed. Symptom (2).
- **Downhill:** reflect is off the SLOPE normal — each contact pops the body
off-slope while tangential speed persists → contact/airborne chain =
the characteristic downhill bounce. Symptom (1).
- **Uphill:** the reflect kills the into-slope component at impact, contact
stands, HitGround fires once, land animation plays. Symptom (3)'s clean
retail counterpart.
## Why acdream glides/flaps instead (the adaptation stack)
`PlayerMovementController.cs:2032-2079` (the per-tick commit) replaces
retail's SetPositionInternal with a hand-rolled block:
1. **AD-25 landing gate:** `if (resolveResult.IsOnGround && Velocity.Z <= 0)`
— needed because *our resolver reports IsOnGround even during an UPWARD
jump (it always step-downs)*. Retail has no such gate: an ascending mover
simply finds no contact plane (it moves away from it; the touch test
fails), so contact clears naturally.
2. **The bounce killer:** `if (Velocity.Z < 0) Velocity.Z = 0` on landing,
whose comment says its purpose plainly: *"makes handle_all_collisions'
landing reflect a no-op — dot(v,n)=0."* This retired the old
"micro-bounce death spiral" — but that spiral was caused by our OWN
gate (reflected +Z defeating the `Velocity.Z<=0` landing test), not by
the reflect being wrong. The workaround deleted retail's legitimate
bounce.
3. With the reflect suppressed, the new #265 residual-velocity fix correctly
preserves landing momentum — which now SLIDES via calc_friction instead
of bouncing. Hence "I glide but that's incorrect."
4. **Uphill flap:** during the up-leg our resolver glues to the slope
(IsOnGround true) while the gate refuses to ground (v.z > 0) →
Contact/OnWalkable and HitGround/LeaveGround edges cycle against the
animation state machine → "weird animations, flapping and gliding."
## Hypotheses (ranked)
1. **H1 (root, high confidence — every link read this session):** the
AD-25 landing gate + Velocity.Z hand-zero must be REPLACED by retail's
SetPositionInternal semantics, which requires first fixing the underlying
resolver divergence: **the transition must not produce a contact plane
for a mover ascending away from the ground** (retail's step-down/touch
conditions do this naturally; ours "always step-downs"). With that fixed,
route the per-tick commit through the already-ported
`CommitSetPositionTransition` and delete the hand-rolled block — reflect,
contact, HitGround/LeaveGround, and land animation then compose exactly
as retail.
- Falsify by: cdb trace on retail (bp SetPositionInternal +
handle_all_collisions, dump v before/after while jumping downhill) —
expect unmodified impact v entering, 5% normal reversal exiting.
2. **H2 (contributing detail):** the garbled `state & <mush>` override in
handle_all_collisions' gate (our port maps it to Sledding) and the
`0x20000` Inelastic mapping need byte-decode confirmation before the
rework — a wrong flag here changes when reflects fire while grounded.
3. **H3 (animation-side residual):** if flap persists after H1, the
MotionInterp land/fall transition (LandJump vs falling-hold) has its own
gate to audit — deferred until H1 is in.
## What we've ruled out
- The transition's landing dual-record being missing — ours is faithful
(TransitionTypes.cs:3410).
- HandleAllCollisions' reflect math/elasticity — ported correctly
(PhysicsObjUpdate.cs:198, elasticity 0.05 default present).
- The #265 residual-velocity fix being wrong — it exposed the missing
bounce; it didn't cause it.
## Recommended next step
Approve H1 for implementation: (a) byte-decode the two garbled flags (H2)
first; (b) find + port retail's exact ascent/step-down gating in the
transition (the one remaining unread mechanism); (c) cut the per-tick commit
over to `CommitSetPositionTransition`; (d) re-run the roof/downhill/flat/
uphill matrix live. Optional pre-implementation confirmation: the H1 cdb
trace against live retail.
## What this is NOT
Not a missing-elasticity port and not a missing collision-record — both
exist and are faithful; the bounce is suppressed by our own landing-commit
adaptation (AD-25 family), whose reason-for-being is the resolver's
ascent-glue divergence.
## Implementation (2026-07-30, user-approved)
All three retail mechanisms are now live; the AD-25 adaptation stack is
deleted:
1. **check_contact seeding** (`PhysicsEngine.ResolveWithTransition`): a body
in transient CONTACT seeds the transition's contact state ONLY while
`v · contactPlane.N <= ε` (0.0002 = PhysicsGlobals.EPSILON, retail
0x0050f5b0); a failing body seeds the last-known plane alone (retail
get_object_info's init_last_known_contact_plane branch). The plane
requirement is strict — Contact-without-plane is unrepresentable in
retail. Body-less callers keep the legacy isOnGround seed (test rigs).
2. **SetPositionInternal-shaped commit** (`PlayerMovementController`): the
`Velocity.Z <= 0` landing gate and the landing `Velocity.Z = 0` hand-zero
are DELETED. Contact commits purely from `resolveResult.InContact` /
`OnWalkable`, HitGround fires on the airborne→walkable edge, and
`HandleAllCollisions` runs with the UNMODIFIED impact velocity — the 5%
elasticity reflect is live. The whole commit is gated on
`resolveResult.Ok && candidateMoved` (retail runs SetPositionInternal
only when the transition succeeded AND the candidate moved — pc:283657;
AD-41's row updated accordingly). Zero-move frames leave contact state
untouched (this is what keeps a standing body stable: a zero-move
resolve cannot re-derive a plane because no sweep runs).
3. **Byte decodes** (this doc's H2): the handle_all_collisions gate override
is `state & 0x800000` = Sledding; the zero branch is `state & 0x20000` =
Inelastic; the reflect fires strictly on `dot < 0` (`test ah, 5; jp`).
Our port had all three correct already — no change.
Settle behavior: a real landing (|v| ≥ 0.25 m/s) bounces at 5% and the hop
chain decays geometrically; sub-0.25 m/s impacts are consumed by retail's
unconditional small-velocity zero (PhysicsBody.UpdatePhysicsInternal), so a
standing body never micro-bounces. calc_acceleration turns gravity off for
Contact+OnWalkable bodies, which is what makes rest bit-stable.
Test re-baselines (each documented in place): the landing-survival pin now
measures decay after the hop chain settles; `LiveCompare_Tick0/376` pin the
new IsOnGround=false on their zero-move ticks (the captured `true` was the
retired seed echo — tick 376's captured body even carries an 11.8 m/s
grounded velocity from the deleted get_state_velocity-overwrite era);
`RemoteDeOverlapMechanismTests.GroundedBody` now carries the plane a real
grounded body always has (the big-creature 1.80 m expectation was calibrated
against the unrepresentable flags-without-plane fixture; production settles
at 1.58 m, unchanged before/after). New pins:
`LandingBounceSeedingTests` (ascent no-seed, rest keeps-contact, strict
plane, slope 5% reversal + tangential preservation, Sledding override).
Verification: complete Release suite 10,031 passed / 5 skips / 0 failures.
Live gate (downhill bounce chain, flat-ground pop, uphill clean landing,
roof slide intact, walking intact) pends the user's next session.

View file

@ -1,618 +0,0 @@
# Movement Parity Audit — Retail vs acdream (2026-07-30)
**Status: COMPLETE — report-only investigation, no code changes made.**
Scope: input → intent → wire → presentation for player + remote movement.
Explicitly OUT of scope (closed by Campaign P, physics/collision proper):
stat chain, friction, sphere lists, leash, PK flags. See
`docs/plans/2026-07-29-physics-parity-campaign.md`'s closeout. This audit
picks up the *movement wire/presentation* seam Campaign P did not touch.
Legend: **FACT** = confirmed against named-retail decomp byte/pseudo-C
(cited address + `acclient_2013_pseudo_c.txt` line) or cross-referenced
against a second independent client (holtburger / Chorizite). **INFERENCE**
= plausible reading where the decompiler dropped x87 detail (a known
Binary Ninja artifact class, see `claude-memory/feedback_bn_decomp_field_names.md`)
and could not be fully disambiguated in this pass.
---
## 1. Outbound semantics table
Retail's outbound tree (from `claude-memory/project_retail_motion_outbound.md`,
re-verified this session):
```
WASD keypress → CommandInterpreter::SendMovementEvent (0x006B4680, per-frame)
→ MoveToStatePack → SendMoveToStateEvent → 0xF61C
at-rest heartbeat → CommandInterpreter::ShouldSendPositionEvent (0x006B45E0)
→ SendPositionEvent (0x006B4770) → AutonomousPositionPack → 0xF753
```
| Intent | Retail send decision | acdream send decision | Verdict |
|---|---|---|---|
| W (run) | `SendMovementEvent` fires on any command-list head edge; wire carries `WalkForward`, `HoldKey.Run`, raw `forward_speed` (pre-scale). ACE auto-upgrades to `RunForward` for observers. | `PlayerMovementController` line 2106-2225: `outForwardCmd=WalkForward`, `outForwardSpeed=1.0f` (raw), `IsRunning=input.Run`; `changed` fires on cmd/hold/speed edges. `RawMotionStatePacker` D1 default-diff omits unchanged fields. | **Parity** (D6.2b/D1 shipped, verified 2026-07-01) |
| W+Shift (walk) | Same tree, `HoldKey.None`, `forward_speed=1.0` (not run-scaled — ACE/observer scaling is a display-time concern, not sender concern). | Same — `axisHoldKey = movement.IsRunning ? Run : None` in `LocalPlayerOutboundController.BuildRawMotionState` (:234-244). | **Parity** |
| Backward (X) | `WalkBackward` tag, own independent forward-channel entry; `adjust_motion` applies a flat **-0.65×** speed multiplier for the walk-forward↔backward pair (`apply_run_to_command`/`adjust_motion` 0x00528010, `acclient_2013_pseudo_c.txt:305343-305400`**FACT**, spot-read confirms the `0x45000006→WalkForward, speed*=-0.65` canonicalization). | `outForwardCmd=WalkBackward`, `outForwardSpeed=1.0f` (PlayerMovementController :2111-2115). The **-0.65× backward scale lives in `MotionInterpreter.cs:543-546`** ("Retail-exact value; do not round to 0.65f") and is applied on the *interpreted* (local-animation) side, not re-derived on the wire (wire stays raw 1.0, matching D6.2b's "ACE recomputes" model). | **Parity** — same separation-of-concerns retail uses (raw wire, scaled interpretation) |
| Strafe (Z/C) | `SideStepRight`/`SideStepLeft`; `adjust_motion` applies a flat **×1.248** (`(3.12/1.25)*0.5`) animation-rate scale, THEN `apply_run_to_command`'s SideStepRight branch (if Run) multiplies by `runRate` and clamps magnitude to **3.0** (`0x00527be0:305102-305122`**FACT** for the 3.0 constant and the runRate scale; **INFERENCE** on the exact snap-vs-clamp branch polarity, x87 flag test unresolved by BN). | `MotionInterpreter.cs:558-564` cites the retail `±3.0` clamp and the 1.248 sidestep scale explicitly; `_activeInputSidestepCommand`/`SidestepUsesRunHold` in `PlayerMovementController.cs:2129-2133` carry the channel through to the wire. | **Parity** (ported; the one open item is the same x87-ambiguous branch retail's own disassembly leaves fuzzy — not an acdream gap) |
| Turn (A/D) keyboard | `adjust_motion` canonicalizes Left→Right (`speed *= -1`), then `apply_run_to_command`'s TurnRight branch multiplies by a flat **1.5×** when hold key is Run (`0x00527be0:305096-305100`**FACT**, byte-confirmed this session). Turn is a channel fully independent of forward/sidestep. | `MotionInterpreter.cs:554` `RunTurnFactor = 1.5f`, applied inside the ported `apply_run_to_command` (:1355+). Turn channel (`_activeInputTurnCommand`/`_activeInputTurnSpeed`) is independent of forward/sidestep in `PlayerMovementController.cs:2139-2143`. | **Parity** |
| Autorun (Q) | See §4 below — separate section, real divergence found. | | **Divergent** |
| Mouse-look turn (MMB) | `CameraSet::ToggleMouseLook`/`Rotate` (0x00457490/0x00458310) drive ordinary `TurnLeft`/`TurnRight` `MovePlayer` calls, always `HoldKey.Run`; speed = 2×filtered horizontal delta, dead-zone 0.02, cap 1.5. `MoveToState` sent on start/stop and every 0.5 s while active. | `MouseTurnDeadZone=0.02f`, `MouseTurnSpeedScale=2.0f`, `MouseTurnMaximumSpeed=1.5f`, `MouseMovementEventInterval=0.5f` (`PlayerMovementController.cs:376-380`) — exact match. | **Parity** (previously verified 2026-07-15, re-confirmed this session) |
| Mouse-move-to (click-to-move) | Not part of the CommandInterpreter WASD tree; routes through `MoveToManager`/`MoveToPosition` (§3). | Same split in acdream (`MoveToManager.cs`, separate from `PlayerMovementController`'s per-frame channel). | **Parity** (architectural match) |
| Stop (S key / all keys released) | `CommandInterpreter::UseTime` gates `ShouldSendPositionEvent` first, then falls through; a full command-list-empty state issues `MovePlayer(Ready, ...)` idle re-sync via `ApplyCurrentMovement`. | `PlayerMovementController` idle path falls to `_motion.RawState.ForwardCommand` staying at `Ready` default (0x41000003), consistent with retail's ctor default. | **Parity** |
### TS-33 residual (exact current-code read)
Register row (`docs/architecture/retail-divergence-register.md:264`, re-read
this session): **"NARROWED 2026-07-15 — full AP tracker semantics are
ported... Residual: acdream's single update path snapshots the AP predicate,
emits a same-update MTS first when input changed, then AP. Retail proves
`UseTime` performs Should→AP, but MTS originates in separate input
callbacks; their relative same-tick callback/wire order is not yet
traced."** This is confirmed still accurate: `PlayerMovementController.cs`'s
per-frame method computes `MovementResult` (lines 2080-2226, the MTS side)
and `LocalPlayerOutboundController.SendPreNetworkActions`/
`SendPostNetworkPosition` (its own file, :50-144) split MTS-before-inbound
vs AP-after-inbound exactly as retail's `UseTime` (0x006B3BF0, decomp
699564-699583) does: `ShouldSendPositionEvent()→SendPositionEvent()` FIRST,
then (separately, from input callbacks, not shown in `UseTime` itself)
`SendMovementEvent`. TS-33's residual is real but narrow: it's an *ordering*
question (does retail's per-frame input callback that calls
`SendMovementEvent` run before or after that frame's `UseTime` AP check?),
not a values/cadence question. Unchanged this session — still needs a cdb
trace to close, not a code fix.
### AP-30 — STALE register row (found this session)
**FACT.** The register (`retail-divergence-register.md:153`) currently
reads: *"AutonomousPosition diff cadence compares with epsilons (1 mm pos,
1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float
compare... `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1110`."*
Both halves of this row are now wrong relative to current code:
1. **Line citation is stale.** Line 1110 of `PlayerMovementController.cs`
today is inside `AttachAnimationRootMotionSource`'s parameter list — unrelated
code. The actual epsilon logic lives at `PlayerMovementController.cs:2234-2263`
(`ApproxFrameEqual`/`ApproxPlaneEqual`).
2. **The epsilon claim is factually wrong about retail.** Read directly
from the named decomp: `Frame::is_equal` (`0x00424c30`, line 38461-38468)
calls `Vector3Math::AreEqual(origin, origin, 0.000199999995f)` and
`Frame::is_quaternion_equal` (0x00424c70, line 38472-38505), which
compares all four quaternion components against the **same
`0.000199999995f` (0.0002) epsilon** — not an exact bit compare.
Likewise `Plane::operator==` (`0x006b3dd0`, line 699713-699743) compares
`N.x`/`N.y`/`N.z`/`d` against the identical `0.000199999995f` epsilon.
`ApproxFrameEqual`/`ApproxPlaneEqual` in current acdream code (lines
2234-2263) use exactly `0.000199999995f` uniformly for both — i.e.
**acdream's current code already matches retail's real (epsilon, not
exact) comparison byte-for-byte**, and the code's own doc-comment says so
correctly ("Retail `Frame::is_equal` ... compares ... with a 0.0002-unit
epsilon"). The register row documents a bug that no longer exists.
**Recommendation:** retire/correct AP-30 in the register (delete the row,
or rewrite it to note the epsilon match is intentional retail parity, not a
divergence) as a small housekeeping fix — no runtime behavior change
needed, since the code is already correct.
---
## 2. Inbound presentation
### 2a. Interpolation catch-up rate — **DIVERGENT, high-severity** (found this session, per coordinator's byte-decode addendum)
**FACT (P-review byte decode, 2026-07-30).** Retail's
`InterpolationManager::adjust_offset`/`UseTime` (0x00555d30/0x00555f20)
gates its catch-up-speed source on a **static flag**,
`InterpolationManager::fUseAdjustedSpeed_` (`.data` at `0x0081f418`,
initialized to `0x1` — confirmed directly, line 1102675):
```
if (fUseAdjustedSpeed_ == 0) catchUpBase = get_max_speed(); // DEAD by default
else catchUpBase = get_adjusted_max_speed(); // the LIVE path
catchUp = catchUpBase * 2.0f; // MaxInterpolatedVelocityMod
```
(confirmed directly, `acclient_2013_pseudo_c.txt:353104-353123`).
`CMotionInterp::get_adjusted_max_speed` (`0x00527d00`, line 305145-305156,
read directly — BN drops the x87 return values into dead-looking
statements, the same artifact class as `get_max_speed`'s ×4 dropout that
UN-2 already resolved by disassembly) is **conditional on the entity's
current interpreted forward command**:
- `forward_command != RunForward (0x44000007)` (i.e. standing, walking,
turning, sidestepping, backing up — anything but an actual run cycle):
returns the **bare run rate** (`InqRunRate`/`my_run_rate`), **no ×4**.
- `forward_command == RunForward`: returns
`interpreted_state.forward_speed ÷ current_speed_factor`, **× 4.0**
(`RunAnimSpeed`, `0x007c8918`) — per the coordinator's disassembly-level
decode (the BN pseudo-C alone drops this trailing multiply, matching the
established `get_max_speed`/UN-2 artifact pattern).
**acdream's current code does not port `get_adjusted_max_speed` at all** —
there is no `CurrentSpeedFactor`/`current_speed_factor` field anywhere in
`MotionInterpreter.cs` (confirmed by grep, zero hits). Every call site that
feeds the interpolation catch-up cap instead calls the **unconditional**
`MotionInterpreter.GetMaxSpeed()` (`MotionInterpreter.cs:2632-2642`, itself
a faithful, byte-verified port of retail's `get_max_speed` alone — always
`runRate × RunAnimSpeed(4.0)`, regardless of forward_command):
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:254`,
`:291`, `:685` (remote NPC/player catch-up)
- `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:174`
- `src/AcDream.App/Input/PlayerModeController.cs:328`
**Consequence:** for any remote entity that is standing, walking, turning,
sidestepping, or backing up (i.e. every state except actively running
forward), acdream's catch-up cap is **exactly 4× retail's** (both use the
×2.0 `MaxInterpolatedVelocityMod`, but acdream also always applies the ×4.0
`RunAnimSpeed` that retail reserves for the RunForward-only branch). For a
run-rate-2.94 character standing still: retail caps catch-up at
2×2.94 ≈ **5.9 m/s**; acdream currently caps it at 2×2.94×4 ≈ **23.5 m/s**
— a 4× overshoot. Only while the remote is genuinely in a `RunForward`
cycle does acdream's flat ×4 approach retail's own (still not identical,
since retail additionally normalizes by `current_speed_factor`, an
unported field).
This existed underneath a prior investigation (UN-2, resolved 2026-06-12,
cited directly in `MotionInterpreter.cs:2601-2630`) that correctly
byte-verified the ×4.0 constant *inside* `get_max_speed`, but did not catch
that `get_max_speed` itself is the **dead default branch** — retail's real
call site always takes `get_adjusted_max_speed`, which only applies that
×4 conditionally. This is a strong root-cause candidate for the "remote
catch-up feels too fast/twitchy for non-running remotes" symptom family
(#41 blips, #165 wall-penetration-before-stop) that the same doc-comment
explicitly says to look elsewhere for — this audit's finding redirects that
search back to this exact seam.
**Recommendation (report-only — no fix applied):** port
`CMotionInterp::get_adjusted_max_speed` as a new `MotionInterpreter` method
(needs `current_speed_factor`, currently absent — a new tracked field,
citing `0x00527d00`/line 305145), and switch every catch-up-cap call site
above from `GetMaxSpeed()` to the new adjusted accessor, gated by the
(retail-fixed-true) `fUseAdjustedSpeed_` semantics — i.e. always call the
adjusted variant, since retail's own flag is always on. This is the
single highest-value fix candidate in this audit.
### 2b. Snap / teleport thresholds — two distinct constants, both present
**FACT.** Retail has two separate thresholds, and acdream has ported both
correctly:
| Constant | Retail value | Retail site | acdream value | acdream site |
|---|---|---|---|---|
| Hard routing snap (give up on interpolation entirely, `MoveOrTeleport`) | **96.0 m** | `CPhysicsObj::MoveOrTeleport` 0x00516330, line 284342-284361 | Not separately named in `InterpolationManager.cs` — this gate lives upstream, at the physics dispatch layer (`RuntimeRemotePhysicsUpdater`/teleport handling), not audited line-by-line this session; flagged as **needs a follow-up grep to confirm the 96 m constant is present at the equivalent acdream call site** (not found during this pass — see gap catalog). | — |
| Enqueue-time "far jump, pre-arm blip" (`AutonomyBlipDistance`) | **100 m outdoor / 20 m indoor** per prior cdb live-attach (project's own 2026-05-0x capture) — the *decomp* constant itself (`GetAutonomyBlipDistance`, 0x0050eb70) is BN-garbled and not independently re-derivable from static text alone this session (**INFERENCE**, cdb-sourced not decomp-sourced) | `CPhysicsObj::GetAutonomyBlipDistance` 0x0050eb70 | `AutonomyBlipDistance = 100.0f` (`InterpolationManager.cs:99`), comment explicitly notes "indoor is 20 m" as a known-but-unported distinction | `InterpolationManager.cs:99` |
**Verdict: Parity** for the enqueue-time 100 m outdoor constant (matches
the project's own prior cdb finding); the indoor-20m variant is
**Divergent/incomplete** — acdream uses a flat 100 m regardless of
indoor/outdoor, an existing known gap already flagged in the code's own
comment (not a new finding, confirmed still present).
### 2c. Position-history queue depth — Parity
**FACT.** Retail: 20 entries (`0x14`), head-evicted on overflow, confirmed
directly at `InterpolateTo` line 353004-353021. acdream:
`QueueCap = 20` (`InterpolationManager.cs:49`), enforced identically
(`Enqueue`, :254-256, `RemoveFirst()` on cap). **Parity.**
### 2d. Stall/give-up mechanics — Parity
**FACT**, all four constants cross-checked directly against the decomp
this session and via the subagent's independent read of
`InterpolationManager.cs`:
| Constant | Retail (line) | acdream (`InterpolationManager.cs`) |
|---|---|---|
| Stall check window | 5 frames (353146) | `StallCheckFrameInterval = 5` (:79) |
| Min progress distance | 0.20 m (353185-353190) | `MinDistanceToReachPosition = 0.20f` (:67) |
| Min progress fraction | 0.30 (353172-353177) | `StallProgressMinFraction = 0.30f` (:86) |
| Fail-count blip threshold | `> 3` (353270) | `StallFailCountThreshold = 3` (:92), fires at 4+ |
**Verdict: Parity.**
### 2e. TS-44 sticky-gated enqueue suppression
Not inside `InterpolationManager.cs` itself — lives in the consumer,
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1637-1643`.
Suppresses a raw `UpdatePosition`-driven snap for an NPC currently
sticky-attached to a target (`PositionManager.GetStickyObjectId() != 0`),
bounded by the ~1 s sticky lease; register TS-44 is **narrowed, not
retired** (per `docs/ISSUES.md` 2026-07-07 pass). User-visible effect:
while a monster is sticky-melee-locked onto a target, an incoming server
position correction that would otherwise snap the NPC is suppressed and
the sticky steering keeps driving it instead — server truth reasserts on
the first UpdatePosition after the lease expires. This is a deliberate,
already-registered adaptation, not a newly found gap.
### 2f. MoveToRunRate consumption
**FACT.** Wire-parsed in `CreateObject.cs:269-296` (`ServerMotionState`
field) and consumed at `LiveEntityNetworkUpdateController.cs:323,433` and
`LiveEntityMotionRuntimeController.cs:394`. A pre-existing, separately
tracked gap (M13 plan doc `docs/research/2026-07-03-r4-moveto/r4-port-plan.md:87`)
notes `MoveToRunRate` feeds the PlanMoveToStart seed but not
`MotionInterpreter.MyRunRate` directly during a live moveto, so
`apply_run_to_command`'s speed scale can use a stale rate mid-MoveTo. Not
re-litigated further this session — flagged as a known, already-filed item
in the gap catalog below (not new).
### 2g. Walk↔Run mid-hold promote/demote render fidelity
**Issue #39** — "Run↔Walk cycle transition not visible on observed player
remotes." **Status confirmed directly this session: CLOSED 2026-07-02**
(`docs/ISSUES.md:7107`). The original 2026-05-06 root-cause ("ACE goes
silent on HoldKey-only toggle") was refuted by a 2026-07-02
three-oracle-plus-live-capture re-investigation
(`docs/research/2026-07-02-inbound-motion-deviation-map.md`, §S0): retail
DOES send a fresh MoveToState on HoldRun toggle while moving, and ACE DOES
rebroadcast it; the refinement machinery #39 built to compensate for the
(non-existent) gap was deleted (commit S5) after it caused spurious
Ready↔Run animation thrash. **CLAUDE.md's phrasing that this is an open
uncertainty ("ACE's behavior on relay is uncertain") is stale relative to
the ISSUES.md record** — worth a small doc correction, not a code gap.
---
## 3. MoveTo/TurnTo parameters
Retail's `MovementParameters` ctor (`0x00524380`, decomp line 300510-300534,
struct verbatim at `acclient.h:31453-31465`) vs acdream's
`MovementParameters.cs` (already cites the same address):
| Field | Retail default | acdream default | Cite | Verdict |
|---|---|---|---|---|
| `MinDistance` | 0.0 | 0f (`:164`) | 300510-300534 | Parity |
| `DistanceToObject` | 0.6 m | 0.6f (`:161`) | same | Parity |
| `FailDistance` | FLT_MAX (3.40282347e+38) | `float.MaxValue` (`:173`) | same | Parity |
| `Speed` | 1.0 | 1f (`:170`) | same | Parity |
| `WalkRunThreshhold` | **15.0 m** | 15f (`:179`) | same | Parity — and acdream's own comment explicitly flags the ACE-divergence trap (ACE uses 1.0) and refuses to copy it |
| `CanCharge` (bitfield 0x10) | **clear (false)** | `false` (`:102`) | same | Parity — same explicit ACE-divergence-trap comment (ACE sets it true by default) |
| `HoldKeyToApply` | `HoldKey_Invalid` | `HoldKey.Invalid` (`:185`) | same | Parity |
**Verdict: full parity.** acdream's `MovementParameters.cs` is a verbatim,
already-well-cited port with correct, explicit call-outs of two known
ACE-vs-retail divergence traps (`CanCharge`, `WalkRunThreshhold`) that it
deliberately does NOT copy from ACE. No gap found here.
### Turn/arrival thresholds beyond the ctor
- `HandleMoveToPosition`'s aux-turn deadband: **20°/340°**
(`MoveToManager.cs:1057`, retail `0x00529d80` line 307187-307438) —
matches the retail function it cites; not independently re-derived from
raw bytes this session (**INFERENCE** on the exact retail constant, but
the citation chain is pre-existing and was not contradicted by anything
found this session).
- `BeginTurnToHeading`/`HandleTurnToHeading` epsilon-snap logic
(`MoveToManager.cs:819-873`, `:1155-1203`) cites retail addresses
`0x00529b90`/`0x0052a0c0` directly; the code's own comments flag two
retail quirks as deliberately preserved: `FailProgressCount` is
write-only in retail (no give-up threshold exists — do not invent one),
and `HandleMoveToPosition` has **no** `set_heading` call (ACE's
"sync for server tickrate" addition is explicitly NOT ported). Both
read as correct, deliberate non-divergences.
### MinDistance vs FailDistance semantics
**FACT** (both retail and acdream, per direct decomp read this session and
independent MoveToManager.cs read): both `MoveToObject` and
`MoveToPosition` share one `movement_params`/`Params` struct and one
handler. `MinDistance`/`DistanceToObject` gates *arrival* (current distance
to target this tick); `FailDistance` gates *give-up* against **total
distance traveled since the move began**, defaulting to FLT_MAX so it is
effectively inert unless a caller tightens it. No object-vs-position
asymmetry exists in either client. **Parity.**
### AP-23 — pickup/use-radius heuristic (current scope)
Register row (`retail-divergence-register.md:148`): an invented per-type
radius bucket (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m
rest) for close-range gating. **Narrowed 2026-07-25 (R5-V3):** the
speculative install now threads the target's real Setup
radius/height (`GetSetupCylinder`) and the player's real radius; only the
bucket bounds remain invented, and Use itself was retired from the
speculative-moveto seam entirely (sends immediately now). Located at
`src/AcDream.App/Interaction/WorldSelectionQuery.cs:78-83` (constants),
`:490-498` (`GetUseRadius`). **One live consumer still cites this seam as
its root mechanism**: `docs/ISSUES.md` issue at line 3825-3826 (2026-07-05,
door-Use-swallowed, HIGH severity), whose resolution is explicitly folded
into Campaign P's physics-parity visual matrix scenario 8
(`docs/plans/2026-07-30-physics-parity-visual-matrix.md`) — i.e. this is
already tracked and pending the user's visual gate, not a newly found gap.
---
## 4. Autorun + mouse semantics
### Retail mechanics (all FACT, byte-read this session)
- **Toggle entry point:** `CommandInterpreter::ToggleAutoRun` (`0x006b3cc0`,
line 699625-699631): `SetAutoRun(auto_run==0, 1)`. Bound via
`CommandInterpreter::HandleKeyboardCommand` (`0x006b3690`, line
699262-699289) on keyboard command `0x90000c7`: reads an **optional
trailing float from the same keybind's argument stream** as
`autorun_speed` (defaults to **1.0** if the keybind carries no extra
argument — confirmed this is the stock case: the retail keymap's
`MovementRunLock [ "" [ 0 DIK_Q ] ]` entry carries no such argument).
- **Default key:** `Q` — confirmed identical in
`docs/research/named-retail/retail-default.keymap.txt:105` and
acdream's `KeyBindings.RetailDefaults():174`.
- **What "on" actually sends:** `CommandInterpreter::ApplyCurrentMovement`
(`0x006b3430`, line 699146-699183): when `auto_run != 0`, retail
unconditionally calls
`MovePlayer(WalkForward(0x45000005), 1, autorun_speed, SetHoldKey=1, HoldKeyToApply=1(Run))`
**autorun ALWAYS forces `HoldKey.Run`**, hard-coded, independent of any
live walk/run toggle state. Since stock `autorun_speed` defaults to 1.0
and the hold key is forced Run, **retail's default autorun always runs**
(WalkForward+HoldKey.Run, which ACE/observers see as RunForward), never
walks, regardless of whether the player has Shift/walk-mode held at
toggle time or afterward.
- **Cancel conditions, two independent mechanisms:**
1. `CommandInterpreter::HandleNewForwardMovement` (`0x006b3d60`, line
699672-699676): **any fresh Forward key press cancels autorun**
(`SetAutoRun(0, 1)`).
2. `CInputManager::ActivateActionKey` (`0x00432650`, line 699243-699258
region, specifically 699496-699502): on a genuine key-down edge
(not a repeat) for action IDs `0x29`/`0x2a`/`0x2b`, calls
`CInputManager::TurnOffRunLock` (`0x004325e0`, line 699424-699442),
which removes the `MovementRunLock` action state and fires its
release-equivalent listener callback. (The exact identity of actions
`0x29`-`0x2b` as raw `CInputManager` action-ID ordinals was not
resolved from static text this session — **INFERENCE** that they are
Backward/StrafeLeft/StrafeRight, based on process-of-elimination
against `HandleNewForwardMovement`'s separate, explicit Forward-only
handling.)
3. Also unconditionally cleared on `LoseControlToServer`,
`PlayerTeleported`, `PlayerIsDead`-detected `MovePlayer` calls, and
`HandleKeyboardCommand`'s own `LoseKeyboardFocus`/death paths.
### acdream mechanics
`RuntimeLocalPlayerMovementState.cs` (`Execute(ToggleRunLock)`, :116-119;
`CancelAutoRun()`, :148-156) + `DispatcherMovementInputSource.cs` (:48-96):
- Default key: **Q** — matches (`KeyBindings.cs:174`).
- **On:** `Forward: forward || AutoRunActive` (:65) — autorun simply forces
the `Forward` boolean true; `Run: !walking` (:71) is evaluated **live,
every poll**, from whatever `InputAction.MovementWalkMode` (Shift) is
currently held — **independent of autorun state**.
- **Cancel set:** `HandlePressedAction` (:80-96) cancels autorun on Press
of `{MovementBackup, MovementStop, MovementStrafeLeft, MovementStrafeRight}`
only.
### Verdicts
| Behavior | Retail | acdream | Verdict |
|---|---|---|---|
| Default key | Q | Q | Parity |
| Pace while autorunning | **Always Run** (hard-forced `HoldKey.Run`, independent of walk-mode toggle) | **Follows the live `MovementWalkMode` toggle** — if the user has Shift/walk-mode held (or toggled) while or after engaging autorun, autorun walks instead of runs | **Divergent.** Confirmed by direct read of `DispatcherMovementInputSource.cs:71` (`Run: !walking`, unconditioned on `AutoRunActive`) against retail's `ApplyCurrentMovement` autorun branch (`SetHoldKey=1, HoldKeyToApply=1` hard-coded, `0x006b3486`). |
| Cancel on Backward/Strafe | Yes (input-layer `TurnOffRunLock`, **INFERENCE** on exact action IDs) | Yes, explicit (`MovementBackup`, `MovementStrafeLeft`, `MovementStrafeRight`) | Parity (functional match) |
| Cancel on Stop key | Not separately identified in retail's cancel set this session (no explicit S/Stop-key cancel site found; likely folds through `auto_run`/`transient_state` reset elsewhere) | Yes, explicit (`MovementStop`) | Likely parity, low-confidence on the retail side |
| **Cancel on fresh Forward press** | **Yes**`HandleNewForwardMovement` explicitly cancels autorun on every new W press (`0x006b3d60`) | **No**`MovementForward` is absent from `HandlePressedAction`'s cancel list (:86-91); architecturally reachable (the same Press edge already drives `CombatAttackInputFrameAdapter.HandleMovementInput`'s abort-for-movement check, `GameplayInputFrameController.cs:24-39`) but not wired to `CancelAutoRun()` | **Divergent, confirmed gap.** In acdream, pressing W while autorunning is currently a no-op (autorun stays latched, `Forward` was already true); in retail, the same press explicitly drops autorun and hands control back to the held key. |
| Mouse-look interaction with autorun | No evidence found of a direct interaction; mouse-look drives its own `TurnLeft`/`TurnRight` channel independent of `auto_run` | Same — mouse-look turn channel (`_activeInputTurnCommand`) is independent of `AutoRunActive` | Parity (no interaction expected on either side) |
| Both-mouse-buttons-run | No evidence found of a distinct "both mouse buttons = run forward" binding in the decompiled `CommandInterpreter`/`CInputManager` text searched this session | Not implemented (no such binding in `KeyBindings.RetailDefaults()`) | **Ruled out as a feature** — this session found no retail mechanism for it, so acdream's absence is not a gap. (If the user recalls this from live retail play, it would warrant a targeted cdb trace on `IInputActionCallback`/mouse-button handlers; not found in the static decomp searched here.) |
---
## 5. Turn-rate composition
**FACT**, direct decomp read this session, `CMotionInterp::apply_raw_movement`
(`0x005287e0`, line 305817-305834) → three independent
`adjust_motion(forward)`, `adjust_motion(sidestep)`, `adjust_motion(turn)`
calls → `apply_interpreted_movement` (`0x00528600`, line 305713-305788)
dispatches `DoInterpretedMotion` **separately** per axis.
- **No cross-axis normalization exists in retail.** Forward, sidestep, and
turn are fully independent scalar channels; there is no diagonal-movement
magnitude clamp (no "moving diagonally isn't faster than moving straight"
logic anywhere in this pipeline) — retail "naively adds commands," to use
the literal reading of `apply_interpreted_movement`'s three sequential,
unconditional `DoInterpretedMotion` calls.
- **Turning while moving backward:** confirmed **no interaction** — the
backward `-0.65×` scale (`adjust_motion`'s `0x45000006→WalkForward`
canonicalization) only touches the forward channel; the turn channel's
own `adjust_motion(turn)` call and its 1.5× run-turn factor are
processed independently with zero shared state.
- **Order of operations relative to dt:** the 1.5× turn multiplier (and
the 1.248× sidestep scale, and the ×4.0/`current_speed_factor` catch-up
math) all operate on the pre-integration **speed scalar**; dt-integration
happens downstream in physics, not inside `adjust_motion`/
`apply_run_to_command`. So "run-turn factor before or after dt scaling"
is moot — it's applied to the same speed value physics later multiplies
by dt, in both clients.
**acdream's port** (`MotionInterpreter.cs`, `adjust_motion` :1290-1321,
`apply_raw_movement`-equivalent :1386-1424) mirrors this structure exactly:
three independent `adjust_motion` calls per axis (:1416, :1420, :1424), no
cross-axis clamp anywhere in the surrounding code, and the code's own
comment (:1268-1271) explicitly documents the same ordering subtlety
retail has (sign-flip on canonicalization happens BEFORE the 1.248
sidestep scale, so the net multiplier for SideStepLeft is `-1.248×speed`,
not `-1×(1.248×speed)` — same value algebraically, but the comment shows
the port tracked retail's actual operation order, not just its result).
**Verdict: full parity.** No combined-input normalization gap found on
either side (neither client has one) — this is a "ruled out" item, not an
open question. Backward+turn and strafe+turn combinations have no special
case in retail and none in acdream, matching.
---
## 6. Wire-format cross-check: holtburger + Chorizite
### RawMotionState / MoveToState / AutonomousPosition bit layout — three-way parity
**FACT.** `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs:45-61`
(`RawMotionFlags` bitflags) is bit-for-bit identical to acdream's
`RawMotionStatePacker.cs:44-55` flag constants (0x001 CurrentHoldKey through
0x400 TurnSpeed, `num_actions` in bits 11+ via
`packed_flags >> 11` matching acdream's `NumActionsShift = 11`), and both
match the named-retail `RawMotionState::Pack` (0x0051ed10) bitfield this
project already ported. `MoveToStateActionData`
(`.../movement/actions.rs:9-18`) field order (raw_motion_state, position,
4× u16 sequence, one trailing byte) matches acdream's `MoveToState.Build`
call shape exactly, including the trailing
`(standingLongjump?2:0)|(contact?1:0)` byte (holtburger's
`contact_long_jump: u8`, same slot). `AutonomousPositionActionData`
(:141-149) matches `AutonomousPosition.Build` field-for-field.
Independently, holtburger's own `AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL`
(`crates/holtburger-core/src/client/movement/common.rs:22`) is
**`Duration::from_secs(1)`** — a third independent confirmation (after
retail's decomp ctor default `0x3ff00000`=1.0 at line 699783, and acdream's
`HeartbeatInterval = 1.0f`) that the AP heartbeat is exactly 1 second across
all three. **Parity, three-way confirmed.**
### Jump packet — acdream matches retail; BOTH holtburger and Chorizite are wrong here
**FACT, byte-verified this session.** Retail's `JumpPack::Pack`
(`0x00516d10`, decomp line 284915-284967, read directly) writes, in exact
order: `extent` (f32) → `velocity.x/y/z` (f32×3) →
**`this->position.vtable->Pack(...)`** (a full `Position` pack: objcell_id
+ frame origin + quaternion) → `instance_timestamp`/`server_control_timestamp`/
`teleport_timestamp`/`force_position_ts` (u16×4) → 4-byte align. This
matches the `JumpPack` **constructor** signature
(`0x00516c70`, line 284887: `float, Vector3 const*, Position const*, u16×4`)
exactly — Position genuinely is part of the wire bytes, not just a
constructor-time convenience.
acdream's `JumpAction.Build(gameActionSequence, extent, velocity, cellId,
position, rotation, instanceSequence, serverControlSequence,
teleportSequence, forcePositionSequence)` (called from
`LocalPlayerOutboundController.cs:73-84`) matches this exactly — this was
already the subject of a correction (memory: "D4 `JumpAction` = retail
`JumpPack` (extent·velocity·Position·4 ts); spurious objectGuid/spellId
removed, Position now packed").
By contrast:
- **holtburger's `JumpActionData`** (`.../movement/actions.rs:73-82`) has
**no `Position` field at all** — instead `extent`, `velocity`, 4×
sequence, then `object_guid: Guid` and `spell_id: u32`. This is the
*pre-correction* shape acdream itself used to have before the D4 fix
(per the same memory note) — i.e. holtburger's Jump model reproduces the
same historical mistake acdream already found and fixed via the named
decomp.
- **Chorizite's `JumpPack.generated.cs`** (`Types/JumpPack.generated.cs:22-83`)
has **neither Position nor object_guid/spell_id** — just `Extent`,
`Velocity`, and the 4 sequence ushorts, then straight to 4-byte
alignment. Also missing the Position bytes.
**Conclusion: acdream's Jump packet is the retail-correct one; do not use
holtburger's or Chorizite's Jump models as a tiebreaker for this specific
packet** — both diverge from the byte-verified retail shape in the same
direction (omitting Position), and holtburger additionally invents
object_guid/spell_id fields that do not exist on the wire. This is a
genuine finding worth remembering for future cross-reference work on this
one packet (not something to act on in acdream — acdream is already
correct), and is exactly the kind of case the project's reference-hierarchy
rule anticipates ("the intersection of the relevant references is almost
always the truth... a single reference can be misleading") — here the
*retail decomp itself*, not the intersection, was the tiebreaker, since two
of three references independently share the same divergence.
---
## 7. Ranked gap catalog
1. **[HIGH] Interpolation catch-up cap is 4× too fast for any non-running remote (§2a).**
`MotionInterpreter` never ported `get_adjusted_max_speed`
(`0x00527d00`) or `current_speed_factor`; every catch-up-cap call site
(`RuntimeRemotePhysicsUpdater.cs:254,291,685`,
`LiveEntityMotionRuntimeController.cs:174`, `PlayerModeController.cs:328`)
uses the always-×4 `GetMaxSpeed()` instead of the conditional accessor
retail's own `fUseAdjustedSpeed_=1` static makes the *only* live path.
Root-cause candidate for observed remote catch-up feeling too
fast/twitchy outside full sprint. **Recommended fix order: first**,
since it's concrete, well-cited, and plausibly explains existing
symptom reports (#41/#165 family) the project has been chasing under
other theories.
2. **[MEDIUM] Autorun always inherits the live walk/run toggle instead of always forcing Run (§4).**
`DispatcherMovementInputSource.cs:71` computes `Run: !walking` every
poll, unconditioned on `AutoRunActive`; retail's `ApplyCurrentMovement`
hard-forces `HoldKey.Run` for the entire duration of an autorun latch
regardless of walk-mode state. User-visible: toggling walk-mode while
autorunning in acdream can make it walk; retail autorun never walks
(absent a custom keybind speed argument, which the stock keymap doesn't
carry).
3. **[MEDIUM] Autorun does not cancel on a fresh Forward (W) press (§4).**
`HandlePressedAction`'s cancel set omits `InputAction.MovementForward`;
retail's `HandleNewForwardMovement` explicitly cancels on every new W
edge. Currently a silent no-op difference (autorun stays latched) that
is architecturally trivial to close — the same Press edge is already
routed through the pipeline for the unrelated combat-abort check.
4. **[LOW, doc-only] AP-30 register row is stale (§1).** Both its file:line
citation and its epsilon claim about retail no longer match reality —
the code already matches retail's real (epsilon-based, not exact)
`Frame::is_equal`/`Plane::operator==` comparison. Recommend
retiring/correcting the row; zero runtime risk either way.
5. **[LOW] Indoor `AutonomyBlipDistance` uses a flat 100 m regardless of indoor/outdoor (§2b).**
Already flagged in the code's own comment as a known simplification (cdb
sourced 20 m indoor vs 100 m outdoor); not a new finding, but grouped
here since it's the one open item in an otherwise clean interpolation
audit.
6. **[LOW, needs follow-up not fix] Confirm the 96 m hard-teleport-snap threshold's acdream equivalent (§2b).**
This session did not locate the acdream call site that mirrors retail's
`MoveOrTeleport` 96 m routing gate (`0x00516330`) — flagged as an
unresolved research gap, not a confirmed divergence. Worth a follow-up
grep for wherever acdream decides "too far to interpolate, snap
instead" at the physics-dispatch layer (outside `InterpolationManager.cs`
itself).
7. **[INFO, no action] CLAUDE.md's "ACE's Run↔Walk relay behavior is uncertain" phrasing is stale (§2g).**
Issue #39 closed 2026-07-02 with the opposite finding (retail does send
a fresh MoveToState on HoldRun toggle; ACE does relay it). Small doc
correction candidate, zero code impact.
8. **[INFO, no action] Two of three wire-format oracles have a wrong Jump packet model (§6).**
holtburger and Chorizite both omit `Position` from their Jump packet
type; acdream's is byte-verified correct. No action needed on acdream's
side — recorded so a future cross-reference pass doesn't get misled by
holtburger/Chorizite's shared mistake on this one packet.
---
## Sources consulted
- `docs/plans/2026-07-29-physics-parity-campaign.md` (scope boundary —
what Campaign P already closed)
- `docs/architecture/retail-divergence-register.md` (rows TS-33, TS-28,
AP-30, AD-57, and the full IA/AD/TS header banners for context)
- `docs/ISSUES.md` (#235, #262, #39, the AP-23 door-Use item at :3825-3826)
- `claude-memory/project_retail_motion_outbound.md`,
`claude-memory/project_input_pipeline.md`,
`claude-memory/project_physics_collision_digest.md` (Campaign P summary
section only)
- `claude-memory/feedback_autowalk_cancharge_bit.md`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — direct reads at
lines 38445-38505 (`Frame::is_equal`/`is_quaternion_equal`), 305062-305156
(`apply_run_to_command`, `get_adjusted_max_speed`), 305160-305199
(`get_state_velocity`), 353095-353135 (`InterpolationManager` catch-up
dispatch), 353261-353344 region, 284887-284967 (`JumpPack::Pack`/ctor),
698940-699850 (autorun/`CommandInterpreter` family), 699560-699830
(`UseTime`, `ToggleAutoRun`, `HandleNewForwardMovement`, `Plane::operator==`,
`CommandInterpreter` ctor), 699120-699220 (`ApplyCurrentMovement`,
`ApplyListHeadMovement`), 55424-55520 (`CInputManager::TurnOffRunLock`/
`ActivateActionKey`), 700233-700420 (`ShouldSendPositionEvent`,
`SendMovementEvent`, `SendPositionEvent`, `SetAutoRun`); plus targeted
greps for `JumpPack`, `apply_run_to_command`, `auto_run`, `Plane::operator==`,
`Frame::is_equal`, `0x45000005`.
- `docs/research/named-retail/retail-default.keymap.txt` (Q=MovementRunLock,
S=Stop confirmed)
- acdream source: `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`,
`LocalPlayerOutboundController.cs`, `RuntimeLocalPlayerMovementState.cs`;
`src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`;
`src/AcDream.Core/Physics/RawMotionState.cs`, `MotionInterpreter.cs`,
`InterpolationManager.cs`, `Motion/MoveToManager.cs`,
`Motion/MovementParameters.cs`; `src/AcDream.App/Input/DispatcherMovementInputSource.cs`,
`GameplayInputFrameController.cs`; `src/AcDream.UI.Abstractions/Input/KeyBindings.cs`,
`InputAction.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs`;
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs`,
`LiveEntityMotionRuntimeController.cs`; `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
- `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs`,
`actions.rs`; `references/holtburger/crates/holtburger-core/src/client/movement/{system.rs,common.rs}`
- `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/JumpPack.generated.cs`,
`Messages/C2S/Actions/Movement_Jump.generated.cs`
- Two Sonnet research subagents (retail-decomp MoveTo/interpolation/turn
research; acdream MoveToManager/InterpolationManager code research) —
their findings were spot-checked directly against the named decomp and
source files in this pass (per `feedback_verify_subagent_claims_against_source.md`);
all spot-checks (MovementParameters ctor defaults, `apply_run_to_command`,
issue #39 status) matched their reports.

View file

@ -1,453 +0,0 @@
# Campaign P — P1 stat-coupled movement: pseudocode + retail chain
Filed 2026-07-30 ahead of the P1 implementation (burden/stamina/vitae feeding
run rate, jump height, jump permission, jump stamina cost). All addresses are
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build) unless marked ACE-cross-reference. Ghidra MCP was unavailable for this
slice (operator note); ACE (`references/ACE/Source/ACE.Server/Physics/`) is
the tiebreaker wherever BN's x87 mush drops a branch, called out explicitly
below.
## 1. The call chain (top to bottom)
```
CMotionInterp (our MotionInterpreter.cs, unchanged this slice)
jump_is_allowed / ChargeJump / JumpChargeIsAllowed
-> WeenieObj.CanJump(extent) [IWeenieObject +0x3C]
-> WeenieObj.JumpStaminaCost(extent, out cost) [IWeenieObject +0x44]
GetJumpVZ -> WeenieObj.InqJumpVelocity(extent, out vz) [+0x30]
apply_run_to_command -> WeenieObj.InqRunRate(out rate) [+0x34]
ACCWeenieObject (thin delegation, pc 406512+)
CanJump/JumpStaminaCost/InqRunRate/InqJumpVelocity/InqMaxRunRate all
gate on IsThePlayer() first (0058c400/40/520/560/5a0) — NPCs/monsters/
remote players never reach m_pQualities for these queries. Confirms P1
is scoped correctly to PlayerWeenie only; RemoteWeenie is untouched.
CACQualities (the "qualities DB" == our PlayerWeenie, pc 412901-414050)
InqLoad 0x0058f130 (pc 409756) — burden/load ratio
CanJump 0x00591b50 — burden hard-gate
JumpStaminaCost 0x00591b90 — stamina cost + PK flag
InqRunRate 0x00592800 — full skill+vitae chain
InqJumpVelocity 0x00592980 — mirrors InqRunRate for Jump
MovementSystem (pure formulas, pc 695958+)
GetRunRate 0x006b0950
GetJumpHeight 0x006b09b0
JumpStaminaCost 0x006b0a40
EncumbranceSystem (pure formulas, pc 256393+)
EncumbranceCapacity 0x004fcc00
Load 0x004fcc40
LoadMod 0x004fcc70
```
## 2. InqLoad (0x0058f130, pc 409756) — FULLY READABLE
```c
InqLoad(this, &loadOut):
strength = InqAttribute(this, ATTRIBUTE_STRENGTH=1) // default 0xa if absent
aug = InqInt(this, PROPERTY_INT_AUGMENTATION_INCREASED_CARRYING_CAPACITY=0xE6 /*230*/)
capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug)
burden = InqInt(this, PROPERTY_INT_ENCUMBRANCE_VAL=5) // default 0 if absent
*loadOut = EncumbranceSystem::Load(capacity, burden)
return 1 // always succeeds for CACQualities (has vtable)
```
The property/capacity shape matches acdream's
`IndicatorBarController.UpdateBurden()` /
`InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6
aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire
value is absent). A 2026-07-31 connected gate exposed one omitted retail
detail: `InqAttribute` returns the enchantment-adjusted attribute, while all
three acdream burden consumers still read raw `AttributeValue.Current`.
Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)`
and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge.
**`AcDream.Core.Items.BurdenMath`
(`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at
the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for
citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather
than re-deriving — one source of truth, no drift between the burden HUD and
movement physics.
## 3. CanJump (0x00591b50, pc 412907) — X87 MUSH, POLARITY RESOLVED BY PLAUSIBILITY
```c
CanJump(this, extent):
load = 0
if (InqLoad(this, &load) != 0):
p = <fcompp load, 2.0f; fnstsw; test ah,0x05> // "load < 2.0" per BN's own
// asserted C0 subexpression
if (!p) return 1
return 0
```
Literal BN reading: `if (!p) return 1` = "if load is NOT < 2.0 (i.e. >= 2.0),
return CAN-jump; otherwise CANNOT". That is backwards from every other
retail-movement fact we have (LoadMod's own floor sits at 2.0; the campaign's
connected-matrix acceptance is "≥200% barely moves/jumps", not "can only jump
when overloaded"). This is the documented BN "bitfield mush" artifact class
(`feedback_bn_decomp_field_names.md`) — the flag-synthesis is unreliable for
the FOLLOWING `test ah,mask` interpretation even when the preceding
subexpression is trustworthy.
**Resolution (register row UN-8, see §6):** `CanJump` returns `load < 2.0`
(can jump under 200% burden; refused at/above it) — the polarity a normal
AC player's lived experience requires, and the one that makes CanJump's own
threshold coincide with `LoadMod`'s floor. ACE gives no tiebreaker
(`WeenieObject.CanJump` is an unconditional `return true` stub — never
ported burden gating at all). Ghidra MCP was down for this slice; flagged
for a future confirmation pass, not blocking this port.
## 4. JumpStaminaCost (0x00591b90, pc 412949) — FULLY READABLE
```c
CACQualities::JumpStaminaCost(this, extent, &costOut):
load = 0
if (InqLoad(this, &load) == 0) return 0
pk = 0
pkStatus = InqInt(this, PROPERTY_INT_PLAYER_KILLER_STATUS=0x86, default=8)
if (pkStatus == 4 || pkStatus == 0x40): // PK / PKLite
pkTimestamp = InqFloat(this, PROPERTY_FLOAT_LAST_PK_ATTACK_TIMESTAMP=0x91)
if (pkTimestamp is present && !(pkTimestamp + 20.0 < Timer::cur_time)):
pk = 1 // PK timer active (<20s since last PK act)
*costOut = MovementSystem::JumpStaminaCost(extent, load, pk)
return 1 // ALWAYS true once InqLoad succeeds — no affordability
// check lives in this function.
```
**Key finding:** retail's `CanQualities::JumpStaminaCost` NEVER returns false
(except when `InqLoad` itself fails, which doesn't happen for a real player).
`jump_is_allowed`'s `if (!WeenieObj.JumpStaminaCost(...)) return 0x47` branch
(the "refusal" path our own `MotionInterpreter.cs` already ports verbatim,
W0-pins.md A2) is real retail *machinery*, but `CACQualities` never actually
exercises the refusing side of it. **"Refused jump" does not happen via this
mechanism in retail — only "weak jump" (see §5).** P1 ports
`JumpStaminaCost` to always return `true` with the REAL computed cost
(retiring the TS-5 zero-cost stub), matching this decomp exactly.
The `pk` flag is `PlayerKillerStatus`/`LastPkAttackTimestamp` — TS-23's
exact scope (P3, not P1). P1 hardcodes `pk: false` at the one new call site
(`PlayerWeenie.JumpStaminaCost`) and documents the dependency against TS-23
rather than re-implementing PK parsing here.
## 5. InqRunRate (0x00592800, pc 413824) / InqJumpVelocity (0x00592980, pc 413902) — FULLY READABLE
Both functions share one shape (Run uses skill id 0x18=24, Jump uses 0x16=22):
```c
InqRunRate(this, &rateOut):
load = 1.0
if (InqLoad(this, &load) == 0) return 0
currentStamina = 0
if (AttributeCache::InqAttribute2nd(attribCache, ATTR2ND_STAMINA=4, &currentStamina) == 0)
return 0
EnchantAttribute2nd(this, 4, &currentStamina) // vital-buff adjusts the LOCAL COPY only
// (not the wire "current stamina" state)
skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // formula-bonus + init + ranks
skill += max(PropertyInt 0x16D, 0) // LumAugAllSkills
skill += matching category augmentation ? 10 : 0 // 0x12C melee / 0x12D missile /
// 0x12E magic; exact skill-id switch
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, truncate
if (PropertyInt 0x146 > 0) skill += 5 // Jack of All Trades
if (skill is specialized) skill += 2 * max(PropertyInt 0x158, 0)
if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism
*rateOut = MovementSystem::GetRunRate(load, skill, 1.0)
return 1
```
`InqJumpVelocity` is identical but for skill id 0x16=22, and finishes with
`sqrt(MovementSystem::GetJumpHeight(load, skill, extent, 1.0) * 19.6)` (pc
413975, matching `GetJumpVZ`'s existing sqrt call already in
`MotionInterpreter.cs`/`PlayerWeenie.cs` — unchanged).
**Answering the plan's question — "which skill level does retail feed?"**
Neither raw base nor a separately-cached value: retail re-derives, on every
query, `EnchantSkill(baseSkill)` where `EnchantSkill` (`CEnchantmentRegistry::
EnchantSkill` 0x005947b0, pc 416240, FULLY READABLE) is:
```c
EnchantSkill(registry, skillId, &valueInOut):
value = *valueInOut // base skill (formulaBonus+init+ranks)
if (registry._vitae != null):
value = Enchant(registry._vitae, value) // vitae multiplier FIRST
matching = CullEnchantmentsFromList(mult_list, category=SKILL=0x10, skillId)
++ CullEnchantmentsFromList(add_list, category=SKILL=0x10, skillId)
for each e in matching: value = Enchant(e, value) // per-record mult OR add
if (value < 0.5) value = 0 // floor
*valueInOut = (int)value // truncate (ftol2)
return ...
```
`CEnchantmentRegistry::EnchantAttribute2nd` (0x00594670, pc 416169, the
vitals path our `EnchantmentMath.GetMod` already ports for
`LocalPlayerState.GetMaxApprox`) applies `_vitae` in the **identical**
position (first, before the mult/add lists) — confirming our existing vitae
representation (`ActiveEnchantmentRecord.Bucket == 4`, a StatModType `Vitae`
flag `0x00800000` classified in `GameEventWiring.ClassifyLiveEnchantmentBucket`)
is the right vehicle: **P1 reuses it unmodified**, adding a sibling
`EnchantmentMath.GetSkillMod` (filtered by `StatModType & Skill(0x10) != 0`
instead of the vitals' implicit attribute2nd filter) rather than inventing a
new vitae channel. This satisfies "vitae/enchant-adjusted effective run/jump
skill... reading vitae + relevant skill enchantments from the M3
active-effect state" without a general effective-skill engine — the only new
code is the type-flag filter and the skill-id key.
**2026-07-31 #268 closeout:** the previously bounded augmentation terms are
now ported in shared `PlayerSkillMath`, after a complete read of
`CACQualities::InqSkill @ 0x00592660`. The exact order matters:
1. intrinsic formula/init/ranks;
2. positive property 0x16D plus the exact category +10 switch;
3. `EnchantSkill`;
4. property 0x146 contributes +5 when positive;
5. specialized skills receive `2 × max(property 0x158, 0)`.
The character panel and Runtime movement both consume this one Core
calculation. AP-127 is retired. The apparent current-stamina-copy residual
does not create an independently reachable effect for ordinary stat
enchantments: current and maximum stamina use distinct secondary-attribute
keys, and a max-stamina enchantment cannot turn zero current stamina nonzero.
## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+)
`GetRunRate` (0x006b0950) and the `arg3!=0` (PK) branch of `JumpStaminaCost`
(0x006b0a40) have their GENERAL-CASE arithmetic entirely dropped by BN (only
the `EncumbranceSystem::LoadMod`/`800`-skill-compare calls and the `arg3==0`
ceil expression survive uncollapsed — the same information-loss class as the
x87 mush, just total rather than partial). **ACE is the cross-reference
tiebreaker for those two spots** (`references/ACE/Source/ACE.Server/Physics/
Animation/MovementSystem.cs`), matching this exact acdream port's ORIGINAL
citation style (`PlayerWeenie.cs`'s pre-P1 doc comments already said
"decompiled + ACE MovementSystem" for these two formulas — nothing new here,
just now with a named-decomp address alongside):
- `GetRunRate(load, skill, scaling) = skill==800 ? 18/4 : ((LoadMod(load) * (skill/(skill+200)*11) + 4) / scaling) / 4`
**§12c correction (#266, 2026-07-30): the 800 branch is EXACT EQUALITY,
not `>=`.** Raw byte decode of 0x006b0950 (PDB-paired binary):
`fild skill; fcom [0x00803b94 = 800f]; fnstsw ax; test ah, 0x44; jp
0x6b097f` — the C2/C3 parity idiom in which `jp` (general path) fires
for `<`, `>`, AND unordered; the `fld [18f]; fdiv [4f]; ret` fall-through
executes only when C3=1/C2=0, i.e. skill == 800 exactly. The general
path decodes instruction-by-instruction to
`(LoadMod(load) * (skill/(skill+200)*11) + 4) / scaling / 4`
(constants 200f @0x00803b8c, 11f @0x00803b88, 4f @0x007c6174, /scaling
from `[esp+0xc]`, final /4f @0x00803b80). **ACE's `>= 800` "max run
speed?" reading is a misread of the same mush and must not be used as a
tiebreaker here** — it flat-lined every maxed character at 4.5 (retail
general formula gives ~3.70) and erased the vitae speed differential
(#266: 33%-vitae +Acdream visibly outran 5%-vitae +Je in acdream while
retail runs them within ~0.4%). `InqMaxRunRate`'s skill=9999 probe gets
the general formula (~3.6961), not 4.5. The true retail signature
carries a 3rd `scaling` arg (confirmed by the decomp's own function
signature), and every known call site (`InqMaxRunRate`, `InqRunRate`)
passes `1f`.
- `GetJumpHeight(load, skill, extent, scaling)` — BN's extent-clamp
micro-branch (pc 006b09b0-006b09ca) is the SAME x87-mush pattern as §3;
ACE's `Math.Clamp(extent, 0, 1)` is the tiebreaker (matches the EXISTING
acdream code, which already does this — unchanged).
`= LoadMod(load) * (skill/(skill+1300)*22.2 + 0.05) * clampedExtent / scaling`,
floored at 0.35 — matches acdream's pre-existing formula exactly.
- `JumpStaminaCost(power, load, pk)`:
- `pk==0`: `ceil((load + 0.5) * power * 8 + 2)` — **the campaign plan's
own shorthand ("ceil((power+0.5)*load*8+2)") has the `+0.5` term on the
wrong operand; the verbatim decomp (fully readable, no mush) is
`(load + 0.5) * power`, confirmed against ACE's identical
`(burden + 0.5f) * power`.**
- `pk!=0`: BN drops the body entirely (bare `_ftol2()` tailcall, no
operands survive); ACE's `(int)((power + 1.0f) * 100.0f)` is the
tiebreaker. Unused by P1 (`pk` is hardcoded `false` — see §4), ported
anyway for signature completeness/citation.
- `EncumbranceSystem::{EncumbranceCapacity, Load, LoadMod}` (0x004fcc00/40/70,
pc 256393+) — already verbatim-ported as `AcDream.Core.Items.BurdenMath`;
P1's `EncumbranceSystem` delegates (see §2).
## 7. What "weak jump" actually is (no hard refusal exists)
Given §4 (JumpStaminaCost never refuses) and §5 (stamina==0 zeroes the
EFFECTIVE skill, not the extent), the retail zero-stamina jump is:
`GetJumpHeight(load, skill=0, extent, 1) = LoadMod(load) * 0.05 * extent`,
floored to the 0.35 m minimum by the function's own clamp — i.e. **every
jump attempt, however exhausted, still produces at least the 0.35 m floor
hop.** There is no code path in `CACQualities` that makes `jump_is_allowed`
return `GeneralMovementFailure` due to low stamina. The campaign plan's
"weak/refused jump" acceptance phrasing is satisfied by "weak" (the floor
hop); "refused" does not occur via burden/stamina in this chain and P1 does
not invent it.
## 8. ReportExhaustion — wiring the dead R3-W4 seam
`ReportExhaustion()` (`MotionInterpreter.cs:1619`, already a full verbatim
port of `CMotionInterp::ReportExhaustion` 0x005288d0) has ZERO callers
anywhere in the codebase today. Retail's caller chain is
`CPhysicsObj::report_exhaustion` (0x0050fdd0) →
`MovementManager::ReportExhaustion` (0x00524360), both outside
`CMotionInterp`'s scope and not yet located precisely in the decomp
(out of P1's bounded scope to hunt down the exact upstream trigger site).
What we DO know precisely: its effect is "re-apply current movement through
the SAME dual-dispatch predicate as `apply_current_movement`" — i.e. force a
fresh `WeenieObj.InqRunRate`/`InqJumpVelocity` query against the CURRENT
physics/interpreted state, with no new input event.
That is exactly the primitive needed to make a live burden/stamina/vitae
change visible immediately (mid-run, mid-charge) instead of waiting for the
next keypress. **P1 wires `ReportExhaustion()` as the "re-evaluate movement
now" trampoline any time Runtime pushes a fresh burden, stamina, or
vitae-adjusted-skill value into the active `PlayerMovementController`** —
plausible given `ReportExhaustion`'s documented purpose, and the least
speculative real consumer available for a seam that otherwise never fires.
## 9. Design: where each input is computed and pushed
```
Runtime (AcDream.Runtime, presentation-free):
RuntimeCharacterState
- Spellbook (existing) -- vitae + skill enchantments live here
- MovementSkills: RuntimeMovementSkillState (existing, EXTENDED)
RunSkill / JumpSkill -- now the ADJUSTED (EnchantSkill'd) values
Burden (float, new) -- InqLoad's load ratio
CurrentStamina (int, new, -1 sentinel = unknown/don't-gate)
- _runSkillBase / _jumpSkillBase (new, private) -- pre-EnchantSkill values
- UpdateMovementSkillBase(runBase, jumpBase) -- stores base, recomputes+pushes adjusted
- RecomputeMovementSkills() -- base * EnchantmentMath.GetSkillMod(skillId), floor/round
- wired: Spellbook.EnchantmentsChanged -> RecomputeMovementSkills (vitae/buff changes
recompute WITHOUT a fresh PD skill push)
LiveSessionEventRouter.Attach() (cross-owner wiring hub; already the home
of the existing onSkillsUpdated -> MovementSkills.Update plumbing)
- onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
- NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
+ Spellbook.EnchantmentsChanged
-> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
using effective/enchantment-adjusted Strength
-> character.Character.MovementSkills.UpdateBurden(ratio)
- NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
-> character.Character.MovementSkills.UpdateStamina(current)
- all three trigger points additionally invoke the new
LiveCharacterSessionBindings.OnMovementStatsUpdated callback
RuntimeMovementSkillProjection.ApplyTo(skills, controller) (existing seam,
called at construction AND reactively from OnSkillsUpdated/OnMovementStatsUpdated)
- SetCharacterSkills(run, jump) (existing)
- NEW: SetCharacterBurden(burden), SetCharacterStamina(stamina)
App (LiveSessionRuntimeFactory) / Headless (HeadlessSessionHost):
- OnMovementStatsUpdated: App wires ApplyTo(...) + controller.Motion.ReportExhaustion()
(mirrors the existing OnSkillsUpdated body, which P1 ALSO extends with
the ReportExhaustion() call for consistency); Headless passes null,
matching its existing OnSkillsUpdated: null (headless bots don't need
live mid-session re-apply to a controller that may not exist yet).
Core (AcDream.Core.Physics, presentation-free, pure):
EncumbranceSystem -- EncumbranceCapacity/Load/LoadMod, delegates to BurdenMath
MovementSystem -- GetRunRate/GetJumpHeight/JumpStaminaCost/GetJumpPower
PlayerWeenie (CACQualities-shaped)
_burden (float), _currentStamina (int?, null=unknown) -- pushed via
SetBurden/SetStamina (SetBurden already existed, wires the dead setter)
_runSkill/_jumpSkill (int) -- pushed via SetSkills, ALREADY vitae/enchant-
adjusted by Runtime before it arrives here (PlayerWeenie itself stays
a pure formula consumer -- no Spellbook/enchantment dependency, keeping
it trivially testable)
CanJump(extent) -> _burden < 2.0 (UN-8 polarity, §3)
JumpStaminaCost(extent, out cost)
-> cost = MovementSystem.JumpStaminaCost(extent, _burden, pk:false);
return true; (§4 -- always true, TS-23 owns pk)
InqRunRate(out rate) -> effSkill = _currentStamina == 0 ? 0 : _runSkill;
rate = MovementSystem.GetRunRate(_burden, effSkill, 1f);
InqJumpVelocity(extent, out vz)
-> effSkill = _currentStamina == 0 ? 0 : _jumpSkill;
vz = sqrt(MovementSystem.GetJumpHeight(_burden, effSkill, extent, 1f) * 19.6f);
```
`_currentStamina == null` (never set — matches every existing test /
call site that doesn't call `SetStamina`) never zeroes the skill, preserving
every pre-P1 `PlayerWeenieTests.cs` expectation unchanged.
## 10. Register bookkeeping (same commit as the port)
- **Delete TS-5** (`CanJump` always true / `JumpStaminaCost` zero-cost stub) —
retired: both now real, decomp-cited.
- **Delete AP-25** (run/jump skill = attributeBonus+init+ranks only, no
vitae) — retired: vitae now flows through `EnchantmentMath.GetSkillMod`.
- **TS-21 untouched** — still valid (pre-PD fallback defaults 200/300 are a
separate divergence, not addressed by P1).
- **TS-23 extended** (not a new row) — its "PlayerKillerStatus not parsed"
scope now also covers the new `MovementSystem.JumpStaminaCost` `pk`
parameter, hardcoded `false` at the `PlayerWeenie` call site pending P3.
- **AP-127 retired 2026-07-31 (#268)** — the complete 0x16D/category/
0x146/0x158 chain is shared by panel and movement (§5 closeout).
- **New UN-8**`CACQualities::CanJump`'s x87 comparison polarity resolved
by domain plausibility rather than a literal BN read (§3); Ghidra MCP
confirmation is the retire path.
## 11. Test plan
- `MovementSystemTests` (new, Core): golden tables for `GetRunRate` (0/200/
800 skill, load knees), `GetJumpHeight` (extent 0/0.5/1, 0.35 floor,
load knees), `JumpStaminaCost` (ceil rounding, load/power sweep),
`GetJumpPower` (inverse sanity, not consumed by P1 but ported for
signature completeness / future charge-meter work).
- `EncumbranceSystemTests` (new, Core): capacity at 100%/200% aug clamp,
Load ratio, LoadMod knees — cross-checked 1:1 against the EXISTING
`BurdenMath` tests (same formulas, must agree bit-for-bit).
- `PlayerWeenieTests` (extend): CanJump refusal at load>=2.0 / allowed
below; JumpStaminaCost real nonzero cost; InqRunRate/InqJumpVelocity
zero at stamina==0 (skill forced to 0, still floors at 0.35 m for jump);
ALL pre-existing tests must stay green unmodified (no SetStamina call ->
null sentinel -> no gating, exactly today's behavior).
- `EnchantmentMathTests` (extend): `GetSkillMod` type-flag filtering
(Skill-flagged records match; vital-only records with a colliding
numeric key do NOT), vitae-first-then-mult-then-add ordering.
- `RuntimeCharacterStateTests` / `RuntimeMovementSkillStateTests` (Runtime):
burden/stamina push + revision bump; `RecomputeMovementSkills` fires on
`Spellbook.EnchantmentsChanged` without a fresh base push; ResetSession
convergence includes Burden==0/CurrentStamina==-1.
- `LiveSessionEventRouterTests` (Runtime, if a harness exists) or a focused
new test: ObjectTable burden-trigger events recompute and push burden;
Stamina vital change pushes CurrentStamina.
## 12. P1 Opus-review addenda (2026-07-30, post-implementation)
### 12a. UN-8 RETIRED — CanJump polarity byte-proven
Raw bytes of `CACQualities::CanJump @ 0x00591b50` in the PDB-paired
v11.4186 binary (technique: `reference_pe_byte_decode`):
```
e8 ca d5 ff ff call InqLoad (0x0058f130)
85 c0 / 74 1a test eax,eax; jz return0 ; load unknowable -> 0
d9 44 24 00 fld dword [esp] ; st0 = load
d8 1d 24 5e 7c 00 fcomp dword [0x007c5e24] ; vs 2.0f (verified read)
df e0 fnstsw ax
f6 c4 05 test ah, 0x05 ; C0|C2
7a 09 jp return0 ; PF=1 on {neither, both}
b8 01 00 00 00 mov eax, 1 ; fall-through: C0 only
```
`test ah,5` result parity: `0x00` (load ≥ 2.0, incl. ==) → PF=1 → 0;
`0x01` (load < 2.0) PF=0 1; `0x05` (unordered) PF=1 0.
**`CanJump = (load < 2.0f)`; NaN/unordered refuses.** The shipped
`_burden < CanJumpLoadThreshold` matches exactly, including the NaN edge.
### 12b. PK-timer jump-cost semantics (for Slice P3 / TS-23)
`CACQualities::JumpStaminaCost @ 0x00591b90` (pc 412934-412968), fully
readable: the `pk` flag passed to `MovementSystem::JumpStaminaCost` is
```
pk = InqInt(0x86 /*134 PlayerKillerStatus*/, default 8) in {4 /*PK*/, 0x40 /*PKLite*/}
&& InqFloat(0x91 /*145*/) succeeded
&& (that_float + 20.0) >= Timer::cur_time
```
i.e. PK/PKLite status AND a 20-second recency window on PropertyFloat
0x91. The P3 implementer should plumb exactly this pair alongside the
mover-flag work; `MovementSystem.JumpStaminaCost`'s pk branch
(`(int)((power + 1) * 100)`, ACE-derived — the retail branch is an
elided `_ftol2` tailcall) is already in place.

View file

@ -1,741 +0,0 @@
# TS-4 / #116 oracle pass — Campaign P final physics slice
**Status: RESEARCH ONLY. No source changes.** This is a follow-up oracle
pass on top of `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md`
(hereafter "the P2 doc"), specifically its §4 (TS-4), §5 (#116), and §7
item 6 (the P2 implementation attempt's wedge diagnosis). That attempt
correctly localized the freeze to `TransitionalInsert`'s Phase 2 retry
loop but concluded the mechanism was "Phase 3 structurally unreachable"
without tracing far enough to find the actual convergence/divergence
point. This pass reads one layer deeper — into `BSPQuery.cs`'s `Path 4`
dispatch (the `path.Collide` gate) and `AdjustOffset`'s crease-projection
math — and finds a concrete, retail-decomp-cited mechanism for both TS-4
and (as a byproduct of reading the same dispatch structure) strong new
evidence for #116 shapes 1 and 2.
Every claim is tagged **FACT** (read directly from the named-retail
pseudo-C, ACE source, or current acdream source in this worktree, with
file:line / address citations) or **INFERENCE** (derived from those FACTs
by direct reasoning, not yet confirmed by a live capture/cdb run).
---
## 0. Binding DO-NOT-RETRY entries (copied verbatim)
From `memory/project_physics_collision_digest.md` (3-day-old snapshot,
re-verified against current source where cited below) and
`docs/ISSUES.md` #116:
1. **Do NOT add `SetSlidingNormal` calls in the BSP/sphere collision
layer.** Retail's only in-transition writer of
`collision_info.sliding_normal` is `validate_transition`
(`0x0050ac21`/`0x0050aa70`). A leaked normal + success writeback = an
absorbing wedge at empty space. **This pass's TS-4 finding is a
variant of exactly this failure class — see §1 below — but the
writer in question (`validate_transition`'s unconditional
`SetSlidingNormal(CollisionNormal)`) IS the retail-faithful one; the
problem is not an extra writer, it's what `AdjustOffset` does with a
*placeholder* `UnitZ` value when it reads `SlidingNormal` back.**
2. **Do NOT re-add a forced constant-shell de-penetration.** Retail
slides tangentially and never force-separates.
3. **`SphereCollision` no longer calls `SetSlidingNormal`** (TS-45
retired) — keep it that way.
4. **Do NOT patch the degenerate-offset guard in `slide_sphere` ad
hoc** for #116 — oracle-driven only.
5. **Do NOT re-introduce a topology-based outside-add / radial sweep**
to cell membership while touching this family.
6. **`calc_friction` threshold is retail 0.25 vs acdream 0.0` (AP-7)** —
orthogonal to this slice, do not fold in.
7. **Shape-1 of #116 is NOT the degenerate-offset guard threshold**
that guard kills slides under ~1.4 cm; the lost tick-22760 slide was
3.57 cm. The divergence is the collision-normal SOURCE.
8. **Do NOT guess the BN `test ah,5` x87 branch polarity/squaring** in
`slide_sphere` — Ghidra MCP is down for this pass too; this pass does
**not** touch that question (see §3, shape-2 — the finding here is
about dispatch *routing*, not the x87 comparisons inside
`slide_sphere`/`AdjustOffset` themselves, which remain unconfirmed
and out of scope).
9. **AP-4 (CliffSlide check moved before retail's Branch-1 gate)** — a
live, load-bearing reordering. Not touched by this pass.
10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE.**
---
## 1. TS-4 — the actual convergence/divergence mechanism
### 1.1 Summary answer (read this first)
**Retail does not "avoid" the Adjusted↔retry oscillation inside
`transitional_insert`'s attempt loop any differently than acdream does —
both structurally deadlock the same way within a single resolve.** What
lets retail's *live* trace escape (and what the P2 fixture's synthetic
trajectory does not) is that **retail's `AdjustOffset`
(`CTransition::adjust_offset`, `0x0050a370`) re-projects the *next
tick's* gravity offset through whatever `ContactPlane` +
`SlidingNormal` survived the previous tick's collision — and for a
pure, zero-horizontal-velocity vertical fall onto a steep surface, that
projection is mathematically degenerate and crushes the offset to
(near-)zero every tick, which abort-small-offsets before
`TransitionalInsert` even runs again.** This is retail-faithful
behavior, present identically in the raw decomp, in ACE's port, and in
acdream's current port — it is not a bug introduced by the TS-4
shortcut's removal. The `Ts4SteepRoofWedgeCaptureTests` fixture
reproduces it because it drops the body **straight down with zero
horizontal velocity**, which is very likely a different (and more
degenerate) input than the live 2026-04-30 debugger trace that
validated the shortcut (a player *jumping or running* onto a roof,
which has residual horizontal velocity).
### 1.2 The chain, FACT by FACT
**Step A — Path 6 fires, sets `Collide`, does not reposition (FACT).**
`BSPQuery.cs:2217-2224` (faithful branch, shortcut removed):
```csharp
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
```
`SpherePath.SetCollide` (`TransitionTypes.cs:752-759`) only sets
`Collide=true`, backs up `CheckPos`, and stores `StepUpNormal` — it does
**not** touch `CollisionInfo.ContactPlane` or `CollisionNormal`. Matches
retail exactly: pseudo-C:323818-323821 (`0x0053a7bf`,
`SPHEREPATH::set_collide(&sphere_path, &normal); walkable_allowance =
0.0871556997f; return 3;`) — no `set_collision_normal`, no
`set_contact_plane` call at this site either.
**Step B — the SAME attempt's retry does NOT re-hit Path 6; it routes to
Path 4 (FACT, both acdream and retail).** `BSPQuery.cs:1961` gates on
`if (path.Collide)` — checked **before** the Path 5/6 tests, at the top
of the same dispatch function. Since `Collide` was just set in Step A
and is **never cleared** except inside `TransitionalInsert`'s Phase 3
(`sp.Collide = false` at `TransitionTypes.cs:1816`, reachable only on an
`OK` result — never reached while Path 6/Path 4 keep returning
`Adjusted`), every subsequent attempt (within the same resolve **and**
across ticks) dispatches to Path 4, not back to Path 6. Retail: raw
pseudo-C:323784 `if (eax->sphere_path.collide == 0) {...} else {...}`
the identical gate, at the identical position in the dispatch (confirmed
independently against ACE `BSPTree.cs:163-187`, `if (path.Collide) {
RootNode.find_walkable(...); if (changed) {... return Adjusted;} else
return OK; }`).
**Step C — Path 4 (`FindWalkableInternal`) is what actually establishes
`ContactPlaneValid` (FACT).** `BSPQuery.cs:1968-2018`: calls
`FindWalkableInternal`; if it finds a candidate (`changed &&
hitPoly is not null`), it **repositions** the sphere
(`path.AddOffsetToCheckPos(worldOffset)`), sets a **real**
`ContactPlane` via `collisions.SetContactPlane(worldPlane, ...)`
(line 2006), caches the walkable polygon (`SetWalkableTransformed`), and
returns `Adjusted`. This is the only site that gives the mover a real
(steep) contact plane in this whole trajectory — **not** the Phase-3
`DoCheckWalkable` gate the P2 doc's item-6 diagnosis assumed was the
relevant site (that gate is downstream and, per Step B, unreachable
here). Matches ACE `BSPTree.cs:163-184` exactly (`SetContactPlane`,
`SetWalkable`, `return Adjusted`).
**Step D — the attempt-exhausted `Adjusted` gets collapsed to `OK` with
position reverted, but `ContactPlaneValid` survives the revert (FACT,
both engines).** `TransitionalInsert`'s outer for-loop exhausts (acdream
hardcodes `return TransitionState.Slid;` at `TransitionTypes.cs:2093`;
ACE/retail return the true last value, `Adjusted` here — see §1.4 for why
this particular divergence doesn't change the outcome). Either way,
`ValidateTransition`'s "not OK" branch runs
(`TransitionTypes.cs:5493-5501`): `if (!CollisionNormalValid)
SetCollisionNormal(UnitZ);` (fires — Path 4/6 never touched
`CollisionNormal`, only `ContactPlane`/`StepUpNormal`), then
`SetCheckPos(CurPos, CurCellId)` (revert — no net movement),
`transitionState = OK`. Retail: pseudo-C:272563-272596 (`0x0050aad9`),
identical collapse (`COLLIDED_TS`/`ADJUSTED_TS`/`SLID_TS` all treated the
same, default `CollisionNormal=UnitZ` if unset, revert `check_pos` to
`curr_pos`). **Crucially, none of this touches `ContactPlaneValid`** — it
carries forward from Step C untouched by the revert. Then the shared
tail (`TransitionTypes.cs:5504-5533`, retail pc:272621-272656) runs:
`if (CollisionNormalValid) SetSlidingNormal(CollisionNormal)` — now
**`SlidingNormal = UnitZ`** (the placeholder from the default, not a
real second surface) — and `if (ContactPlaneValid) { ...;
oi.State|=Contact; if (Normal.Z>=FloorZ) OnWalkable=true else false; }`
— since the steep polygon's `Normal.Z` (≈0.447 for the fixture's 63.4°
slope) `< FloorZ` (≈0.664), `OnWalkable` stays **false** but `Contact`
becomes **true**. **This exactly reproduces the fixture's own captured
state at the landing tick: `InContact=true, OnWalkable=false`.**
**Step E — the NEXT tick's `AdjustOffset` crushes a purely-vertical
offset to zero (FACT for the math, INFERENCE that this is the actual
observed freeze cause — not independently re-run this pass).**
`TransitionTypes.cs:4936-5014` (acdream), `Transition.cs:34-87` (ACE),
pseudo-C:272271-272393 (`0x0050a370`, retail) are all structurally
identical:
```
slidingAngle = Dot(offset, SlidingNormal)
if (SlidingNormalValid) { if (slidingAngle < 0) checkSlide = true; else SlidingNormalValid = false; }
...
if (checkSlide) {
slideOffset = Cross(ContactPlane.Normal, SlidingNormal)
normalize slideOffset (or zero out if degenerate)
result = Dot(slideOffset, offset) * slideOffset
}
```
With `offset = (0, 0, -dz)` (pure gravity, zero horizontal component),
`SlidingNormal = UnitZ = (0,0,1)`: `slidingAngle = -dz < 0`
`checkSlide = true`. `slideOffset = Cross(ContactPlane.Normal, UnitZ)`
for any non-vertical plane normal `N=(Nx,Ny,Nz)`, this cross product is
`(Ny, -Nx, 0)` — a **horizontal** vector (Z=0), lying in the slope's
*contour* line (perpendicular to the downhill direction), **not the
degenerate/near-zero case** (the 63.4° slope's normal is not parallel to
UnitZ, so `NormalizeCheckSmall` does not fire). `Dot(slideOffset,
offset) = Dot((Ny,-Nx,0), (0,0,-dz)) = 0` exactly, because
`slideOffset.Z = 0` and `offset` is purely `Z`. **`result = 0 *
slideOffset = Vector3.Zero`.** The projected `GlobalOffset` is zero (up
to float noise), which trips the "abort-small-offset" guard
(`TransitionTypes.cs:1466-1478`, retail's non-viewer `|offset|² <
F_EPSILON²` gate at pseudo-C:272845/`0x0050bdf0`, cited already in the
existing `AdjustOffset` port comment) **before `TransitionalInsert` is
even called again** — so `ValidateTransition` never runs on subsequent
ticks either, meaning the stale `ContactPlaneValid`/`SlidingNormal=UnitZ`
state simply perpetuates unchanged, forever. This is the freeze.
**Step F — why the existing frames_stationary_fall (fsf) escape valve
can't rescue this case (INFERENCE, follows directly from Step E).** The
digest's #182 rebuild already ported retail's fsf ladder
(`TransitionTypes.cs:5625-5667`, ACE `Transition.cs:1029-1061`,
pseudo-C:272625-656) — after 3 consecutive non-advancing ticks it
manufactures a flat `UnitZ` contact plane and forces `OnWalkable=true`,
which is exactly the kind of "unstick" mechanism one would look for
here. **But that ladder lives inside `ValidateTransition`, which Step
E's abort-small-offset guard prevents from ever running again** once the
crease projection first crushes the offset to zero. The rescue mechanism
is downstream of a gate the degenerate input never lets execution
reach — in both acdream and (per identical source) retail.
### 1.3 Why this reconciles the shortcut's own "retail did not wedge" comment (INFERENCE)
The shortcut's comment (`BSPQuery.cs:2190-2199`) says the interim fix was
"Validated against retail debugger trace 2026-04-30: retail body did not
wedge." A live player jumping or walking onto a roof virtually always
carries **some** horizontal velocity component (WASD input, residual
momentum). For a non-purely-vertical `offset`, `Dot(slideOffset, offset)`
is generally **non-zero** (only a component exactly along the pure
downhill/gravity line is annihilated by this specific cross product —
any lateral drift survives), so `AdjustOffset` would produce a small but
non-zero *sideways* offset each tick — enough to move the sphere off the
exact same collision point, avoid the abort-small-offset short-circuit,
let `TransitionalInsert`/`ValidateTransition` run again, and (via
repeated Path-4 `find_walkable` re-probes and the fsf ladder) eventually
resolve. **The `Ts4SteepRoofWedgeCaptureTests` fixture's `pos =
(0.5, 0, 3.0)` straight-down drop with `fallVelocityZ` as the only
non-zero component is very likely a stricter, more degenerate input than
the live 2026-04-30 repro ever exercised.** This is not yet independently
re-confirmed by re-running the fixture with a horizontal component (see
§4 Step 1 below for the concrete next action), so it is flagged
INFERENCE — but it is the only hypothesis consistent with every FACT
gathered in §1.2, and it does not require inventing any new mechanism.
### 1.4 The acdream-only bug that does NOT explain the freeze, but is real and should still be fixed
`TransitionTypes.cs:2091-2093`:
```csharp
// Exhausted retry attempts — return whatever the last iteration said.
// (Defaults to Slid in practice since that's the only case that retries.)
return TransitionState.Slid;
```
This is **hardcoded**, not "whatever the last iteration said" as the
comment claims. ACE's equivalent (`Transition.cs:933`, `return
transitState;`) and retail's (pseudo-C:273363, `0x0050b949`, `return
edi;`) both return the **true** last value — `Adjusted` in this
scenario, not `Slid`. **FACT: this is a real, citable divergence.**
**FACT: it does not explain the freeze** — `ValidateTransition`'s
"not OK" branch (§1.2 Step D) treats `Collided`/`Adjusted`/`Slid`
**identically** (acdream `TransitionTypes.cs:5493-5501`, ACE
`Transition.cs:993-1017`, retail pseudo-C:272563-272596 all gate on
`result > OK_TS && result <= SLID_TS` as one combined range, with no
per-value branching). Fixing the hardcoded return is a one-line,
zero-risk correctness fix (worth doing — it's a real citable
port-accuracy bug and prevents future confusion when tracing this loop)
but it is **not** the TS-4 fix and should not be presented as one.
### 1.5 What TS-4's actual fix shape is, given this
The mechanism in §1.2 is **not something `BSPQuery.cs`'s Path 6 can fix
by itself** — the freeze happens one tick *after* Path 6/Path 4 run,
inside `AdjustOffset`, and is a property of the (already retail-faithful)
`validate_transition` + `adjust_offset` pipeline reacting to a specific
degenerate trajectory. Concretely, TS-4's shortcut removal is very
likely **safe for the realistic case** (nonzero horizontal velocity) and
only exposes this specific zero-horizontal-velocity degenerate, which:
- may be a genuine, narrow, retail-faithful edge case (a player falling
perfectly plumb onto a slope with zero horizontal drift essentially
never happens in live play — WASD input, camera-relative movement, and
even tiny numerical noise almost always inject some horizontal
component), in which case it is not a blocker for TS-4 at all and
should be documented as an accepted (retail-matching) corner case
rather than "fixed", **or**
- may indicate the fixture itself should be revised to match the
original live repro's actual trajectory shape (nonzero horizontal
velocity) before it's trusted as TS-4's gating fixture.
See §4 for the concrete, low-cost verification step (re-run the fixture
with a small horizontal velocity component) that would settle which of
these is true without guessing.
---
## 2. #116 shape-1 — collision-normal recording divergence (new candidate, INFERENCE, needs one instrumentation run to confirm)
### 2.1 What the existing research already ruled out (FACT, restated)
Ghidra-confirmed (2026-06-12, digest lines 1268-1275): acdream's
`cn=UnitZ` default on a blocked move **is** retail-faithful
(`validate_transition` does the identical default). The real divergence
is **upstream** — at tick-22760, acdream's `collision_normal_valid` was
`false` where retail's was `true` (retail had recorded the door-face
normal `(0,+1,0)`). The candidate site named in the P2 doc §5 was "the
`PathClipped`/`collide_with_pt` arm... or a sibling Path-1-class function
not yet read."
### 2.2 PathClipped is NOT the answer (checked this pass, negative result — FACT)
`ObjectInfoState.PathClipped` (`TransitionTypes.cs:32`, bit `0x8`) is
only set on a mover when `MoverPhysicsState & PhysicsStateFlags.Missile
!= 0` (`PhysicsEngine.cs:1160-1163`), with an explicit citation to
retail's own `CPhysicsObj::get_object_info` (`0x00511CC0`): "Missile
contributes PathClipped only." A normal player push against a door is
not a missile mover, so **neither acdream nor retail would set
PathClipped for this scenario** — this rules out "PathClipped state
differs between engines" as shape-1's cause. (The camera/viewer sweep
does carry PathClipped via a different, explicit caller-supplied flag,
but that's a different mover than the one in the tick-22760 door-push
capture.)
### 2.3 The real candidate: acdream's Path-6 sphere1(head)-hit handling diverges from retail/ACE (FACT for the divergence, INFERENCE that it explains tick-22760)
Retail's `BSPTREE::find_collisions`, in the **not-yet-in-Contact**
branch (`state&1==0`, i.e. airborne / first contact — pseudo-C:323784-
323836, `0x0053a4e3`-`0x0053a730`+): when sphere0 (foot) does **not**
hit but `num_sphere > 1` and sphere1 (head) **does** hit, retail does
**not** defer through `SetCollide`/`Adjusted` — it calls
`COLLISIONINFO::set_collision_normal` **directly** with the head poly's
transformed normal and returns `COLLIDED_TS` (`2`) immediately
(pseudo-C:323824-323834, `0x0053a793`/`0x0053a7a4`). Cross-checked
independently against ACE `BSPTree.cs:221-230`:
```csharp
else if (path.NumSphere > 1)
{
if (RootNode.sphere_intersects_poly(localSphere_, movement, ref hitPoly, ref contactPoint) || hitPoly != null)
{
var collisionNormal = path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal);
collisions.SetCollisionNormal(collisionNormal);
return TransitionState.Collided;
}
}
```
— an exact structural match to the raw decomp, confirming this is not a
BN misdecompile.
**acdream's corresponding code (`BSPQuery.cs:2227-2264`) does NOT do
this.** It applies the *same* SetCollide-and-defer (or steep→`Slid`)
treatment to a sphere1 hit as it does to sphere0 — there is no branch
that returns `Collided` with a direct `SetCollisionNormal` write for "foot
clear, head hit" while airborne. This means: **in acdream, an airborne
mover whose HEAD sphere alone contacts a polygon (foot sphere clear) gets
`SetCollide` + deferred `Adjusted` (no immediate `CollisionNormal`
write) — exactly the same "the real normal gets lost until
`validate_transition`'s `UnitZ` default kicks in" symptom the digest
already diagnosed for shape-1.** A door push where the player's capsule
brushes the door frame near chest/head height while the foot sphere
tracks slightly differently (a very plausible geometry for "pushing a
closed door face at a near-perpendicular angle," matching the tick-22760
description) is a strong candidate for exactly this code path.
**Caveat, stated honestly:** this is contingent on sphere0 (foot) *not*
fully hitting while sphere1 (head) *does* — if the door's collision
geometry is a full vertical plane, sphere0 would very likely hit too,
and the code would never reach the sphere1 branch (`BSPQuery.cs:2156`'s
`if (hit0 || hitPoly0 is not null)` returns early). This has **not**
been confirmed against the actual tick-22760 replay this pass — it is
the single next concrete step (see §4).
### 2.4 Instrumentation to run (concrete, low-cost, no guessing required)
Extend `DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals`
(`tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:162`)
to log, at the tick-22760 resolve, which of `hit0`/`hitPoly0`/`hit1`/
`hitPoly1` were non-null/true inside `BSPQuery.cs`'s Path-6 dispatch
(a one-line `Console.WriteLine` gated behind the existing
`ProbeIndoorBspEnabled`/`ProbeBuildingEnabled` diagnostics, or a new
narrowly-scoped probe flag per the project's diagnostic-owner pattern).
**Accept criterion:** if `hit0`/`hitPoly0` are both null/false **and**
`hit1`/`hitPoly1` fire, §2.3's hypothesis is confirmed — the fix is to
port retail's direct sphere1-hit-without-sphere0-hit → `Collided` +
`SetCollisionNormal` branch into `BSPQuery.cs`'s Path 6 (mirroring the
already-correct Path 5/Contact-branch treatment at
`BSPQuery.cs:2103-2140`, which already handles the analogous grounded
case correctly — this would be a narrow, well-precedented port, not a
new design).
**Reject criterion:** if sphere0 hits too (`hit0` or `hitPoly0` truthy),
this hypothesis is wrong for tick-22760 specifically, and the search
should move to the *other* named-retail sibling not yet read this pass —
`BSPTREE::collide_with_pt`'s own internal structure for a **non-PathClipped**
context is not reachable (its outer gate requires `state&8`), so the
next candidate would be whatever governs `CObjCell::find_obj_collisions`'s
insertion order relative to `find_env_collisions` for a door's *building*
channel (the BR-7/A6.P4 per-cell shadow architecture) — not yet examined
this pass; would need a fresh read of that dispatch specifically for
polygon ordering/precedence when multiple candidate polys are tested per
cell.
---
## 3. #116 shape-2 — first-airborne-frame hard-stop vs in-frame slide (strong structural finding, INFERENCE, narrows but does not eliminate the need for a confirming run)
### 3.1 The dispatch structure resolves the ROUTING question without cdb (FACT, cross-referenced against 3 sources: raw BN pseudo-C, ACE, current acdream)
Both the raw retail decomp and ACE's `BSPTree.cs` (an independent,
clean-language port — the "fastest oracle" the mission suggested)
show the **same two-tier gate**, keyed on `ObjectInfoState.Contact`:
- **Already grounded (`Contact` set) + head-sphere hit**`slide_sphere`
called **directly, in-line, same tick** (ACE `BSPTree.cs:192-202`;
retail pseudo-C region immediately following `0x0053a730`'s `state&1`
branch — the `else` arm at ~323838+, not fully re-quoted here but
structurally mirrored by ACE's clean port). acdream's `BSPQuery.cs`
Path 5 (`:2103-2120`) already matches this exactly — `SlideSphere`
called directly for a grounded head-hit.
- **NOT yet grounded (`Contact` unset, i.e. airborne / first contact) +
foot-sphere hit** → the **Path-6 default**: `SetCollide` +
`WalkableAllowance=LandingZ` + return `Adjusted` — **no
repositioning, no `slide_sphere` call at all** (ACE `BSPTree.cs:210-219`;
retail pseudo-C:323815-323821). Only a sphere1(head)-hit-without-
sphere0-hit gets an immediate response in this branch, and that
response is `Collided` (§2.3), **still not `slide_sphere`**.
**This means: for a genuine first-airborne-frame FOOT-sphere wall hit
(the D4 fixture's actual shape — a mover falling into a tall wall),
neither retail nor ACE's port calls `slide_sphere` on contact frame 1.**
The sphere is left exactly where it was (`SetCollide` does not
reposition — confirmed in §1.2 Step A), `Collide` gets set, and the
**very next retry attempt** (same tick, same `TransitionalInsert` loop,
per §1.2 Step B) routes to **Path 4** (`find_walkable`) instead. For a
**tall, vertical wall** (D4's actual geometry — "TallWall" per the test
name), `find_walkable`'s nearby-walkable-surface search would very
plausibly find **no** candidate (a sheer vertical face has no
near-horizontal polygon to "land" on nearby) — `changed=false` — so
Path 4 returns `OK` (ACE `BSPTree.cs:185-186`, `else return
TransitionState.OK;`). `TransitionalInsert`'s Phase 3 (`if
(sp.Collide)`, now finally reachable since `objState==OK`) then runs:
`ContactPlaneValid` is **false** (Path 4's `changed=false` arm never
sets it), so the `else reset=true;` branch fires
(`TransitionTypes.cs:1842-1843`), `RestoreCheckPos()` reverts to the
pre-hit position, and the retail-faithful gate at
`TransitionTypes.cs:1863-1898` (matching pseudo-C:273231-273239 exactly,
already cited in-code) fires: since this is the *first* airborne
contact, `LastKnownContactPlaneValid` is false, so
`SetCollisionNormal(sp.StepUpNormal)` (the wall's **real** normal,
captured back at the original Path-6 hit) runs and the function returns
**`Collided`** — a **hard stop, in place, with the correct wall normal
recorded** — not a slide.
### 3.2 What this means for D4
**INFERENCE, well-supported but not independently re-run this pass:**
the D4 pin's original expectation (frame 1 hard-stops at Z=2.0, the
slide begins frame 2 off the cached sliding normal) is structurally much
closer to what retail's own dispatch produces for a true vertical-wall
first-contact than the engine's current in-frame slide-to-Z=1.92
behavior. **This narrows — but does not eliminate — the open question.**
What remains genuinely unconfirmed by static reading (and is exactly
the class of question DO-NOT-RETRY item 8 warns against guessing):
- Whether `find_walkable`'s internal walkable-search radius/height
actually returns "nothing found" for the *specific* D4 fixture
geometry (a wall tall enough that no nearby floor exists within its
search envelope) — this is a **testable, non-cdb** question: instrument
or step through `FindWalkableInternal` for the D4 geometry and confirm
`changed=false`.
- The exact x87 comparison polarities *inside* `slide_sphere` and
`find_walkable` themselves (unrelated to this pass's routing finding)
remain unconfirmed per DO-NOT-RETRY item 8 — but those don't matter for
D4 if `slide_sphere` is never reached on frame 1 in the first place.
### 3.3 Recommended next step for shape-2 (no cdb needed for the routing question; cdb only if the confirming run disagrees)
1. **First (cheap, no cdb):** run/instrument the existing
`BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames`
fixture (currently `Skip`-tagged citing #116) with a probe on which
`BSPQuery.cs` path fires on frame 1 (Path 6 vs Path 4 vs a full-hit-
the-second-attempt path) and whether `FindWalkableInternal` returns
`changed=true` or `false` for that specific wall. **Accept:** if
Path 6 fires (`SetCollide`+`Adjusted`, no reposition), Path 4 then
fires with `changed=false`, and the final result is `Collided` with
`StepUpNormal` as the recorded normal — this confirms §3.1/§3.2, and
the fix is to **flip the D4 pin back to hard-stop** (retire the
`Skip`, assert Z=2.0 frame 1) rather than changing the engine.
**Reject:** if the trace shows something else (e.g. Path 4 actually
finds a walkable candidate for this wall, or a different dispatch arm
fires entirely) — then the routing hypothesis in §3.1 doesn't hold for
this specific fixture geometry, and a live cdb trace becomes necessary
after all.
2. **Only if step 1 disagrees with the FACT-cited dispatch structure:**
a live cdb trace of an actual airborne wall hit in retail, per the
CLAUDE.md "Retail debugger toolchain" section. Concrete script
outline (adapting the documented pattern):
```
.logopen ts4-116-airborne-wallhit.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
bp acclient!BSPTREE::find_collisions "r $t0 = @$t0 + 1; .if (@$t0 % 1 == 0) { .printf \"hit %d: state=%%d collide=%%d\\n\", @$t0 } gc"
bp acclient!CSphere::slide_sphere "r $t1 = @$t1 + 1; .printf \"SLIDE_SPHERE HIT #%d\\n\", @$t1; .if (@$t1 >= 3) { qd } .else { gc }"
bp acclient!BSPTREE::collide_with_pt "r $t2 = @$t2 + 1; .printf \"COLLIDE_WITH_PT HIT #%d\\n\", @$t2; gc"
g
```
User reproduces: jump toward a tall vertical wall so the FIRST wall
contact happens while airborne (not already grounded). The key
signal is whether `slide_sphere` fires on the **same** engine tick
as the first `find_collisions` hit against that wall (in-frame slide,
confirming the CURRENT engine behavior) or only on a **later** tick
(confirming the hard-stop-then-slide-frame-2 pin). Auto-detaches via
`qd` after 3 `slide_sphere` hits to bound game lag.
---
## 4. Recommended execution order + blast radius
1. **[Lowest risk, do first] Fix the `TransitionalInsert` exhausted-loop
hardcoded return** (§1.4): change `return TransitionState.Slid;` to
return the real last `transitState` value, matching ACE/retail. Blast
radius: essentially zero — `ValidateTransition` treats
`Collided`/`Adjusted`/`Slid` identically downstream (confirmed §1.2
Step D), so this is a pure code-correctness fix with no observable
behavior change in any currently-passing test. Good precursor because
it removes a misleading comment/return before anyone traces this loop
again.
2. **[Cheap, decides whether TS-4 needs anything further] Re-run
`Ts4SteepRoofWedgeCaptureTests` with a small horizontal velocity
component** (e.g. `vx = 0.3` m/s alongside the existing straight-down
fall), shortcut removed. Per §1.3's hypothesis, this should **not**
wedge (the crease projection produces a non-zero tangential offset).
**Accept (doesn't wedge):** TS-4's shortcut removal is safe for the
realistic case; land it, retire the TS-4 register row, and either (a)
accept the pure-vertical case as a documented, retail-faithful corner
case (cite §1.2/§1.3 in the register row) or (b) if the team wants
zero residual risk, also file a narrow follow-up for the
zero-horizontal-velocity degenerate specifically (not a TS-4 blocker).
**Reject (still wedges even with horizontal velocity):** §1.3's
hypothesis is wrong or incomplete; do NOT land TS-4 yet — re-open with
a fresh capture of the actual velocity vector at the wedge point and
compare against what `AdjustOffset` computes step by step (a
`ACDREAM_DUMP_EDGE_SLIDE`-style trace of `AdjustOffset`'s intermediate
`slidingAngle`/`collisionAngle`/`slideOffset` values, not yet
instrumented, would be the concrete next apparatus).
3. **[Independent of 1-2] #116 shape-1 instrumentation** (§2.4): add the
one-line hit0/hitPoly0/hit1/hitPoly1 probe to
`Diagnostic_Tick22760_DumpEngineInternals` and re-run. Blast radius:
zero (diagnostic-only). If confirmed, the fix (porting retail's direct
sphere1-hit → `Collided`+`SetCollisionNormal` branch into Path 6) is a
narrow, well-precedented addition mirroring the already-correct Path 5
treatment — moderate blast radius (touches the shared Path-6 dispatch
used by every airborne two-sphere mover), needs the existing
`SphereCollisionFamilyTests`/`Issue137*` suites re-run plus a fresh
tick-22760 comparison before landing.
4. **[Independent of 1-3] #116 shape-2 instrumentation** (§3.3 step 1):
add the BSPQuery-path + `FindWalkableInternal` `changed` probe to the
D4 fixture. Blast radius: zero (diagnostic-only) for the instrumentation
itself. If confirmed, flipping the D4 pin (un-skip, assert hard-stop
frame 1) is a **test-only** change with **zero production code
change** — the engine's current dispatch already produces this
result per §3.1's reading; only the test's own expectation is
currently wrong. This is the lowest-risk of all four items once
confirmed, because it requires touching zero engine code.
**Suggested order given the above:** 1 → 4 → 3 → 2, since 4 (#116
shape-2) is the cheapest to fully resolve (test-only fix, zero engine
change, per this pass's structural finding) and 2 (TS-4's own
confirming run) benefits from having item 1's return-value fix landed
first (removes a confusing false signal before re-tracing).
---
## 5. What genuinely still needs cdb or Ghidra (not resolved by this pass)
1. **#116 shape-2, only if §3.3 step 1's confirming run disagrees with
the FACT-cited dispatch structure.** The routing question itself
(does frame 1 reach `slide_sphere`) is resolved by static reading
against 3 independent sources in this pass; only a surprising,
contradicting instrumentation result would re-open the need for a
live trace. The cdb script outline is in §3.3 step 2.
2. **The x87 comparison polarities inside `slide_sphere`,
`find_walkable`, and `AdjustOffset`'s own internal branches**
(DO-NOT-RETRY item 8) — untouched by this pass, remain Ghidra/cdb-
gated as before. This pass's findings are about which *function*
gets called (dispatch routing), not the exact comparison operators
inside those functions.
3. **AP-7's `cos(10°)` vs `0.99999536f` discrepancy** (P2 doc §1) —
unrelated to this pass, still needs a Ghidra decompile of
`0050ee70` when Ghidra MCP is back up.
4. **TS-1 gaps #2/#3's `last_known_contact_plane` maintenance and
Path-4 `LandingZ` acceptance audit** (P2 doc §2, §6 Step 2) — per the
current source read in this pass, this already carries an in-code
citation ("TS-1 gap #3 (register AD-54, Campaign P Slice P2
2026-07-30)") suggesting it was addressed in the same implementation
session that produced the P2 doc's item-6 update; not independently
re-verified this pass.
---
## 6. One-paragraph summary for the calling agent
**TS-4:** the Adjusted↔retry loop the P2 doc's implementation attempt
found is real, but its root cause is one layer downstream of where that
attempt looked. `Path 6` sets `Collide=true` without moving the sphere;
every subsequent attempt (same tick and later ticks, since `Collide` is
never cleared outside Phase 3) routes to `Path 4`
(`FindWalkableInternal`), which is what actually establishes the steep
`ContactPlane` (matching the fixture's observed `InContact=true,
OnWalkable=false`). The freeze itself happens one tick later, inside
`AdjustOffset`: `validate_transition`'s retail-faithful `CollisionNormal
→ UnitZ` default feeds `SetSlidingNormal`, and `AdjustOffset`'s
crease-projection (`Cross(ContactPlane.Normal, SlidingNormal)`) is
mathematically orthogonal to a **purely vertical** input offset — every
subsequent tick's gravity-only offset gets crushed to zero and
abort-small-offsets before the engine can run again. This exact
mechanism is present identically in the raw retail decomp, ACE's port,
and acdream's current port — it is very likely not a code bug but a
narrow degenerate case that a live player's residual horizontal velocity
(present in the original validating debugger trace) would not trigger.
The concrete next step is cheap and decisive: re-run
`Ts4SteepRoofWedgeCaptureTests` with a small horizontal velocity
component before deciding whether TS-4's shortcut removal needs anything
beyond the register-row writeup.
## Addendum (P-review byte decode, 2026-07-30): AD-55 RESOLVED — retail's sled flatness test is cos(10°), ACE's constant is a radians/degrees bug
Raw bytes of `CPhysicsObj::calc_friction @ 0x0050ee70` (PDB-paired binary,
technique `reference_pe_byte_decode`), Sledding fast-sled branch at
0x0050ef52-0x0050ef6a:
```
d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.Normal.Z
dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; = 0.17453292519943295 (10 deg in RADIANS)
d9 ff fcos ; st0 = cos(10 deg) = 0.984807753
de d9 fcompp
df e0 / f6 c4 41 / 7a fnstsw; test ah,0x41; jp
```
FACT: retail genuinely computes `cos(10°) ≈ 0.9848078` at runtime and
compares `Normal.Z` against it. ACE's `0.99999536f` equals
`cos(0.1745 DEGREES)` — the radian literal evaluated in degree mode; a
proven ACE porting error, not a BN artifact. Sibling constants
byte-confirmed: threshold float 0.25 @0x007c6b00, doubles 6.25/1.5625
@0x007c6b30/38, friction overrides 1.0f/0.2f as immediates.
Feel impact: retail's 0.2-friction fast-sled override engages on ground
within 10° of flat; the shipped ACE-derived constant engages only within
0.17° (never, in practice) — part of the #166 sled family. FIX (queue for
the TS-4/#116 implementation slice, which owns `PhysicsBody`): replace
`0.99999536f` with `0.98480775f` (cos 10°), cite this addendum, retire
register row AD-55 in the same commit.
## Addendum 2 (implementation session, 2026-07-30): #116 shape-1's tick-22760
## confirming run DISAGREES with this plan's hypothesis — the real mechanism
## is one layer further upstream, and it's retail-faithful there too
Per this doc's own §4 execution order, item 1 (the `TransitionalInsert`
exhausted-loop hardcoded return) landed first — mechanical, zero
observable behavior change, confirmed by the full `AcDream.Core.Tests`
suite (4059 passed / 2 skipped, no change in pass count). Then §2.3's
shape-1 fix landed verbatim in `BSPQuery.cs`'s Path 6 `hasSphere1`
branch: a foot-clear/head-hit airborne contact now returns
`TransitionState.Collided` with a direct `collisions.SetCollisionNormal`
write, exactly matching pc:323824-323834 (`0x0053a793`/`0x0053a7a4`) and
ACE `BSPTree.cs:221-230`. This is a real, independently-decomp-confirmed
port-accuracy fix and is kept regardless of the result below.
**The confirming instrumentation run (§2.4) DISAGREES with the plan's
tick-22760 hypothesis.** Re-running
`DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals`
after the fix landed shows **no change**: harness still reports
`cn=(0,0,1)` (the `UnitZ` ground-fallback default) against live's
`cn=(0,+1,0)` (the door-face normal). Adding a dispatcher-entry probe
(`[path-dispatch]`, `[path5-diag]`, gated on the existing
`ProbeIndoorBspEnabled` flag, kept in `BSPQuery.cs` as permanent
diagnostics) traced the ACTUAL call sequence for this capture:
1. The seeded body's `TransientState` (131 = `Contact | OnWalkable |
Active`) means `ObjectInfo.State & Contact != 0` for this mover — it
is **grounded**, so `BSPQuery.FindCollisionsCore` dispatches to
**Path 5** (the Contact/grounded branch), never Path 6 at all. The
plan's shape-1 hypothesis was explicitly scoped to "the not-yet-in-
Contact branch" (§2.3) — that scoping was itself the unconfirmed
part, and it does not hold for tick-22760.
2. Path 5's own dispatch for the door's BSP shape at this exact position
finds **neither sphere hitting nor near-missing**
(`hit0=False hitPoly0=False hit1=False hitPoly1=False`) — the
simplified fixture registration this test uses
(`BuildEngineWithDoorFixture`, which places the raw GfxObj BSP
directly at its captured world-space bounding-sphere center rather
than via the faithful `ShadowShapeBuilder.FromSetup` +
`PlacementFrame` transform that `BuildFaithfulDoorEngine` uses
elsewhere in the same file) returns `OK` for the door here.
3. `TransitionalInsert`'s step-down gate then fires
(`contactInvalidOrSteep` is true because the per-substep walk loop
clears `ContactPlaneValid` before every `TransitionalInsert` call —
`TransitionTypes.cs` around the `FindValidPosition` per-step reset —
so the door BSP is queried TWICE MORE via `DoStepDown`'s two half-
height attempts, dispatching to **Path 3** (`StepSphereDown`
`FindWalkableInternal`), which also finds no walkable candidate here
(the door face is not a floor-like polygon) and returns `OK` both
times.
4. Both `DoStepDown` calls therefore fail (return `false`), which routs
into `EdgeSlideAfterStepDownFailed`. With `ContactPlaneValid` false,
`OnWalkable` true (seeded), `EdgeSlide` true (mover flags), and the
RESTORED walkable polygon from the body's own snapshot (a flat
triangle `(144,0,94)-(144,24,94)-(120,24,94)`, `Normal.Z=1 >=
FloorZ`), execution reaches `sp.PrecipiceSlide(this)`
(`TransitionTypes.cs` "branch3/precipice-slide").
5. **`SpherePath.PrecipiceSlide` calls `BSPQuery.FindCrossedEdge` against
that seeded triangle. The player's actual sweep (X≈133, Y from 18.02
to 17.60) does not cross ANY of that triangle's three edges** (the
triangle spans roughly X∈[120,144], and its hypotenuse sits at
X+Y=144 — at X=133 that's Y≈11, far south of the player's Y range).
`FindCrossedEdge` returns false, and acdream's `PrecipiceSlide`
(`TransitionTypes.cs:1039-1054`) does exactly what retail's
`SPHEREPATH::precipice_slide` does on the identical branch — **read
fresh this session, pc:274316-274326, `0x0050cc80`**:
```
int32_t eax = CPolygon::find_crossed_edge(...);
if (eax == 0) { this->walkable = eax; return 2; /* COLLIDED_TS */ }
```
**No `set_collision_normal` call on this path in retail either.**
This is a byte-exact match, not an inference — acdream's
`ClearWalkable(); return TransitionState.Collided;` on a failed
`FindCrossedEdge` is retail-faithful. `ValidateTransition`'s
`UnitZ`-default-on-invalid-normal fires identically in both engines
for this exact mechanism.
**Conclusion: the tick-22760 divergence is NOT explained by anything this
plan identified, and the mechanism this pass traced down to (Path 5 →
StepSphereDown → EdgeSlideAfterStepDownFailed → PrecipiceSlide's
no-crossed-edge fallback) is independently confirmed retail-faithful at
every step, including a fresh byte-level read of `precipice_slide`
itself.** The remaining candidates, none guessed at here: (a) this
specific harness (`BuildEngineWithDoorFixture`) may simply not place the
door's BSP polygons where live retail's did at that exact tick — a
harness/fixture-geometry gap, not a response-layer code bug — worth
re-running this same capture through `BuildFaithfulDoorEngine`'s
Setup-based registration to check whether a REAL BSP hit against the
door (rather than the seeded generic floor triangle) changes the
outcome; (b) the seeded `WalkableVertices` triangle itself may not match
what retail's own walkable-polygon bookkeeping held at that instant
(a state-capture gap in the original 2026-05-24 live-capture tooling,
not necessarily an engine bug); (c) a genuinely different upstream
mechanism not yet traced. Per CLAUDE.md's no-guessing rule, none of
these is adopted without further evidence — #116 shape-1 stays
**narrowed, not closed**: the Path-6 fix is a real, independent
retail-faithfulness improvement, and the original tick-22760 acceptance
criterion is NOT met by it. See ISSUES.md #116 for the updated status.

View file

@ -1,104 +0,0 @@
# Issue #269 — slope-stop capture and retail correction
**Date:** 2026-07-31
**Status:** implemented; user live gate passed
**Scope:** landing-bounce follow-up, `CTransition::validate_transition`
## Symptom
After the retail 5%-elasticity landing reflection was restored for #265,
the character could retain too much downhill speed after landing on a
walkable slope. The user described the residual as “slides too far on
landing.”
## ACDream live capture
`ACDREAM_CAPTURE_PLAYER_QUANTA=<jsonl-path>` records the local player's
complete admitted object quantum without changing simulation order:
1. quantum start;
2. root/PositionManager composition;
3. pre- and post-`UpdatePhysicsInternal`;
4. transition result;
5. final collision-response commit.
The accepted repro contained 2,184 quanta. The clearest landing was:
| Quantum | Event | Velocity |
|---|---|---|
| 1740 | final airborne quantum | `(-12.316, 8.187, -26.266)` |
| 1741 | slope collision, normal `(-0.236, 0.236, 0.943)` | |
| 1741 post-response | correct 5% reflect | `(-17.391, 13.262, -6.576)` |
| 17421758 | still Contact + OnWalkable, no new collision normal | velocity unchanged |
| 1759+ | contact relationship changes | friction finally begins decaying |
The reflected velocity had `dot(v, normal) = +1.0252`: it pointed away
from the slope. Retail `calc_friction` correctly skips while this value is
at least `0.25`, so friction was not the defect. ACDream was repeatedly
restoring the remembered slope plane and re-grounding the body without
performing retail's accompanying velocity stop.
## Retail oracle
Named-retail:
- `CPhysicsObj::check_contact` `0x0050F5B0`
- `CPhysicsObj::get_object_info` `0x00511CC0`
- `CTransition::validate_transition` `0x0050AA70`
- `OBJECTINFO::kill_velocity` `0x0050CFE0`
The exact `validate_transition` order at
`0x0050AAED0x0050AB42` is:
1. enter only for a non-OK collision/adjusted/slid result;
2. if `last_known_contact_plane_valid`, call
`OBJECTINFO::kill_velocity`;
3. test the current sphere center against the remembered plane using
`radius + 0.0002`;
4. restore the contact plane only when still within that distance;
5. later, at `0x0050ACFF`, overwrite last-known validity with final
contact-plane validity.
`OBJECTINFO::kill_velocity` calls
`CPhysicsObj::set_velocity({0,0,0}, 0)`. ACDream had ported the proximity
test and plane restore but omitted this call. It also allowed the
last-known plane to re-ground clean accepted moves, although retail only
consumes it in the non-OK recovery branch.
## Correction
`Transition.ValidateTransition` now:
- calls `ObjectInfo.StopVelocity()` before the remembered-plane
proximity/restore test on a non-OK recovery;
- performs that restore only in the retail branch;
- overwrites last-known validity from final contact validity, so a clean
move away cannot be re-grounded from stale memory.
The existing `PhysicsEngine.ResolveWithTransition` consumption of
`VelocityKilled` applies the zero to the canonical `PhysicsBody` before
the collision-response tail. The initial 5% landing reflection remains;
only a following collision recovery performs the retail stop.
## Gates
- New focused pins:
- collision recovery with a remembered plane kills velocity;
- clean advance with a remembered plane neither kills nor re-grounds.
- Full `AcDream.Core.Tests`: 4,107 passed / 2 skipped.
- Full `AcDream.Runtime.Tests`: 439 passed.
- `AcDream.App` Release build: 0 warnings / 0 errors.
- Complete Release suite: 10,061 passed / 5 skipped / 0 failed.
- User live gate: **PASS** — repeated slope jumps now settle correctly
(“Perfect! Works great!”).
## Diagnostic tools retained
- `tools/analyze_269_slope_stop_capture.py`
- `tools/cdb/run-issue269-slope-stop.ps1`
- `tools/cdb/issue269-slope-stop.cdb`
The cdb runner refuses to attach unless the live retail executable matches
the Sept 2013 named PDB. The locally installed 2015 retail executable does
not match; the static named-retail decode above is therefore the retail
oracle used for this correction.

View file

@ -1,108 +0,0 @@
# #271 — Stair-side uphill reversal capture
**Date:** 2026-07-31
**Status:** closed; retail control flow restored and user live gate passed
## Symptom
When the local player ran diagonally uphill while pressing into the side of
an outdoor staircase, the character could suddenly move backward and rapidly
slide to the bottom. The symptom was intermittent because it required the
forward candidate to hit the side wall while the step-down recovery crossed a
tread edge.
This is not an RDP, render-rate, animation, or gravity symptom. It reproduced
inside the pure Core collision resolver from one captured input frame.
## Live evidence
The bounded capture is under the ignored local artifact pointer:
`artifacts/issue271-stair-side/LATEST.txt`
It contains 677 local-player physics quanta plus the matching resolver stream.
The first decisive frame is quantum 310:
```text
current = (133.03775, 75.53931, 59.608147)
target = (133.33783, 76.42308, 59.608147)
input = forward + run
result = (133.18779, 75.19872, 59.316677)
normal = (-1, approximately 0, approximately 0)
```
The X side-wall collision was valid, but the tangential Y component reversed:
an uphill request of `+0.88377` produced `-0.34059`. Three frames later,
quantum 313 snapped from Z `59.52598` to terrain Z `58.005`. A second attempt
reproduced the same family at quanta 479482, falling from Z `60.96376` to
`58.005`.
## Retail oracle
Named retail:
- `CTransition::edge_slide` at `0x0050B3D0`
- current-walkable branch at `0x0050B44A`
- no-walkable back-probe at `0x0050B4580x0050B50F`
- `SPHEREPATH::precipice_slide` at `0x0050CC80`
Retail tests only the current `SPHEREPATH::walkable` pointer. If it is null,
retail:
1. offsets the failed candidate back to the current sphere center;
2. runs `step_down` there to rediscover the surface actually under the mover;
3. restores the failed candidate;
4. runs `precipice_slide` against the newly discovered polygon; or
5. returns `COLLIDED_TS` when the back-probe found no walkable polygon.
Retail has no substitution of an older saved walkable polygon in either null
case.
## ACDream divergence and root cause
`EdgeSlideAfterStepDownFailed` previously called
`SpherePath.RestoreLastWalkable()`:
- before deciding whether to enter the retail back-probe; and
- again when the back-probe found no current walkable polygon.
`LastWalkable` is a separate ACDream history used by the still-open
CliffSlide compatibility path. At a staircase side wall it could describe the
preceding tread rather than the surface below the current player position.
Promoting it into the current slot bypassed retail's back-probe.
`PrecipiceSlide` then projected the failed forward candidate along the stale
tread edge, producing the backward/downhill displacement seen in the capture.
The fix removes both stale-history promotions from the edge-slide dispatch.
Current walkable state still takes retail's direct precipice path; absent
state now always takes retail's current-position back-probe.
## Deterministic regression
`Issue185OutdoorStairsSeamReplayTests` reuses the captured
`0x01000AC5` staircase collision fixture and the exact quantum-310 position,
contact plane, movement delta, player flags, and 1.5 m Setup step-down height.
Pre-fix:
```text
out = (133.187790, 75.346481, 59.430882)
```
Fixed:
```text
out = (133.187790, 76.078186, 60.016247)
```
The regression requires meaningful positive uphill progress and forbids a
downhill Z displacement. The complete Core Release suite passes 4,108 tests /
2 skips; the complete Release solution passes 10,062 tests / 5 skips.
## Live acceptance
The user repeatedly ran uphill while pressing into both sides of the affected
staircase. Movement remained stable and the former rapid downhill reversal did
not recur. The client then closed through the normal logout path, with ACE
confirming graceful logout.

View file

@ -1,174 +0,0 @@
# Atomic collision-generation activation (Slice 3B)
## Retail anchor
Retail hydrates a cell synchronously. `CObjCell::init_objects`
(`0x0052B420`) visits objects associated with that cell and invokes
`CPhysicsObj::recalc_cross_cells` (`0x00515A30`). The final position path also
replaces shadows as one `SetPositionInternal` operation (`0x00515330`). Retail
therefore never exposes a world where the new cell exists but the objects that
overlap it still have their old cross-cell set.
Acdream streams a landblock over several update frames. Literal per-cell
mutation during those frames was not equivalent: the active `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object
refloods changed at different cursors. Collision queries could observe a mixed
generation, and correctness depended on a later optional landblock callback.
## Ported adaptation
The asynchronous unit is now one Runtime-owned collision generation:
1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation.
2. `PrepareCollisionGeneration` creates empty private cache, graph, engine, and
shadow facades and retains the active aggregate root reference in O(1).
Global immutable GfxObj/Setup catalogs are not copied; the accepted build's
exact closure is populated by the existing cursors.
3. Stable
landblock and logical-owner slot suffixes then materialize each non-target
cache, CellGraph, engine, and shadow leaf into an empty private root under
the host's existing frame meter. The 32-resident-landblock gate proves
admission performs no resident copy and every advance reports at most one
work unit.
4. App and Headless publish terrain, EnvCells, topology, buildings, prepared
collision assets, and target-root static owners only into that private
generation.
5. Stable per-prefix owner slots capture every non-suspended owner that touches
or has a withdrawn repair marker for the target prefix. That includes live
dynamic owners and statics rooted in an adjacent landblock. Only a
target-root static is omitted, because the authored replacement supersedes
it. The scan has a fixed slot suffix and is unaffected by mutations in other
prefixes. Vacated slots are tombstoned and reused rather than retained for
the whole session. A single Runtime-scoped versioned journal records each
mutation once and coalesces repeated changes by logical owner, independently
of the number of live drafts. After topology sealing, each draft reconciles
the latest exact state of owners changed during that draft's lifetime one
owner per seal call. A discovered relevant owner then receives scoped exact
updates, preserving continuous-motion progress without restoring global
fanout. A membership transition is routed by the owner's changed landblock
prefix to the one matching draft, so an owner first entering or leaving the
target after its global journal slot was visited is still reconciled once.
During topology construction, a visited unrelated owner retains only a cheap
coalesced notification; its exact mirror runs later as one metered seal unit
rather than once per draft on the mutation path. Once the topology seal
exists, observed owners temporarily write through exactly until same-call
activation. The finite pre-seal queue therefore drains even when two or more
unrelated owners mutate before every host step. Slots predating a newer root snapshot are superseded
by a tail slot, not reused behind live cursors. New drafts begin at their
captured suffix, obsolete slots compact one visit per seal call, and the
journal clears when its last draft closes. Unrelated and continuously moving
owners therefore never restart capture or sealing.
6. Explicit one-work-unit cursors build the complete replacement before the
activation frame: requested global collision records, cells/topology,
buildings, cell graph removals, affected static owners, retained-owner
states, and removal lists. A late unarmed relevant owner consumes at most
one refresh unit on a seal call; when that drains the queue an already-built
seal is immediately ready. Immutable global GfxObj/Setup closure entries are
preinstalled during these metered steps, not during activation.
7. Cache, CellGraph, engine-landblock, and shadow topology share one
`CollisionWorldStateSlot`. `CommitCollisionGeneration` transfers the
complete off-side aggregate through one volatile reference on the update
thread, then revokes the staging slot. The public `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, and `ShadowObjectRegistry` facade identities
stay stable. Warm 256-owner, cold
first-load, changed EnvCell/building, and new static-bucket gates all measure
exactly zero managed bytes in final activation. Only afterwards does Runtime
emit `CollisionGenerationCommitted` and a ready acknowledgement.
8. Multiple landblocks may prepare concurrently. Preparation order is the
activation order. Only after an older generation commits is its exact delta
queued into every later draft. Each additional seal call applies at most one
cache, CellGraph, synthesized outdoor-cell, engine-landblock, or logical
owner leaf. Later generations retain their own completed target seal but
cannot activate before every committed delta drains. Cancelled older drafts
therefore contribute nothing, final activation performs no peer work, and a
later root cannot overwrite or expose an older snapshot. Seam-crossing
statics are forcibly re-evaluated against the later topology. Demotion and
withdrawal cancel a matching queued or active rebase, suppress that prefix
in unfinished source scans, and retire one shadow owner, cache/graph leaf,
authored outdoor cell, or landblock leaf per later seal call before
activation. Retirement storage is growable rather than coupled to the
concurrent-preparation limit, and final commit rechecks both pending rebase
and retirement work after the seal-to-commit gap.
The host performs the zero-work root transfer in the same update-thread call
that completes final reconciliation, eliminating a seal-to-next-frame quiet
window for continuously moving unrelated owners.
9. GfxObj/Setup closure entries are immutable content-addressed catalog data,
not world topology. Their metered early installation may survive a cancelled
generation as ordinary process cache residency; no cell, building,
landblock, or shadow becomes visible through that catalog alone.
Presentation and no-window hosts use the same Runtime transaction. Network
workers still enqueue immutable messages and cannot mutate collision or shadow
state.
## Failure and lifetime rules
- A newer admission invalidates an older prepared generation.
- Cancellation names one admission and its private staging generation. It can
never withdraw or demote the active landblock, and cancelling a stale receipt
cannot invalidate a newer admission.
- Demotion, withdrawal, reset, and disposal invalidate the admission before
changing the active generation.
- Disposing a stale/cancelled prepared generation clears only its private
engine/cache/shadows.
- The prior complete generation remains queryable throughout preparation.
- The commit notification is the future lost-cell-registry seam. Slice 3B does
not implement `GotoLostCell` or change `SetPosition` recovery behavior.
## Deterministic evidence
The focused Runtime/App tests pin:
- previous terrain/cells/buildings/statics remain visible until commit;
- exactly one notification after a successful complete activation;
- stale admission replacement has no active-world side effect;
- unrelated movement on every capture/seal step never restarts the target;
- two relevant owners moving on every seal step converge without restarting
the topology meter and install their latest positions at activation;
- an authoritative state change on a retained rowless owner updates an
already-sealed generation without a global restart;
- a neighboring static whose shadow crossed the seam is restored atomically
on reload and its withdrawn-prefix marker clears only at activation;
- a late spawn blocks activation until its one metered refresh; deletion of an
armed owner writes through directly;
- Headless faults immediately after admission and after staging preserve the
prior complete world and leave no collision admission behind;
- dense sealing consumes at most one work unit per call, while warm 256-owner,
cold first-load, changed EnvCell/building, and new static-bucket activation
all allocate zero managed bytes;
- concurrently prepared landblocks rebase and preserve both terrain roots and
static-shadow owners across their activation order, with zero-byte final
commits and revoked staging access;
- dense 32-landblock admission performs no resident copy, stays within its
constant allocation envelope, and materializes at most one leaf per advance;
- cancelled older drafts contribute no topology to later roots, while a live
owner mutation after the older commit wins over the queued rebase;
- a newly committed seam-crossing static refloods against the later draft's
topology before that draft may activate;
- post-seal arrivals drain one owner per seal call without resetting capture;
- an unrelated owner entering the target after its journal slot was visited is
routed by prefix and reconciled in one metered seal unit;
- target departure and same-ID reuse preserve the exact new-prefix owner rows;
- unrelated state mutation publishes only after every row changes;
- unrelated demotion/withdrawal and the live `CurrCell` cannot be resurrected
or rolled back by a later draft;
- queued and partially applied peer rebases cannot resurrect a later demoted or
withdrawn landblock;
- deleting an outgoing target static before, during, or after staging cannot
erase an authored same-ID replacement;
- 10,000 repeated mutations with 32 drafts retain one coalesced journal entry
and allocate no more than the owner mutation itself;
- 512 unique changed owners reconcile in exactly 512 metered seal units and
the final activation still allocates zero managed bytes;
- compacted journal slots are never reused behind a live cursor, while a
4,096-slot obsolete tail retires incrementally and a later draft starts at
its captured suffix rather than scanning old tombstones;
- post-seal retirement blocks activation until its cursor drains, and more
than 256 distinct retirements remain metered and lossless;
- prefix-owner slots remain bounded under GUID churn and empty containers are
reclaimed across unique prefixes without invalidating a live seal cursor;
- graphical and no-window publishers use the same Runtime transaction;
- removal and terminal teardown converge the active ownership ledger.
This retires divergence row AD-6. The remaining lost-cell state-machine work is
deliberately outside this slice.

View file

@ -1,333 +0,0 @@
# Canonical retail `SetPosition` — placement/streaming Slice 4A
## Scope
This note pins the pure physics half of the placement/streaming closeout.
Slice 4A lands the retail placement transaction as a separately testable Core
mechanism. It deliberately does **not** replace the production snap-only
resolver yet: Runtime lost-cell ownership and the complete inbound-route
cutover remain Slice 4B. AP-1 and AD-1 therefore remain active until that
cutover is complete.
Named-retail oracle, Sept 2013 EoR:
- `CPhysicsObj::SetPosition` `0x005160C0`
- `CPhysicsObj::SetPositionInternal` `0x00515BD0`
- `CPhysicsObj::AdjustPosition` `0x00511D80`
- `CPhysicsObj::CheckPositionInternal` `0x00511E90`
- `CTransition::find_valid_position` `0x0050C310`
- `CTransition::find_placement_position` `0x0050C170`
- `CTransition::find_placement_pos` `0x0050BA50`
- `CTransition::validate_placement_transition` `0x0050ADC0`
- `CTransition::validate_placement` `0x0050B210`
- `CPhysicsObj::ForceIntoCell` `0x00515660`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
## Retail transaction
```text
SetPosition(request):
transition = makeTransition() // GENERAL_FAILURE if none
init_object(transition, object)
if the PartArray has no spheres:
init_sphere(1, dummy center=(0,0,0.1), radius=0.1, scale=1)
else:
init_sphere(first min(count,2) authored spheres, exact object scale)
if flags & RANDOM_SCATTER (0x200):
return scatter only
result = SetPositionInternal(request)
if result != OK and flags & SCATTER (0x100):
return scatter
return result
SetPositionInternal(request):
AdjustPosition(request frame, first sphere, noCreate=(flags & 0x20))
if no resident cell:
store the adjusted authoritative frame and enter lost-cell lifetime
return OK
if the live weenie is Hook, Storage, or Corpse:
return ForceIntoCell(resident cell, frame)
set do_not_load_cells from flag 0x20
if !CheckPositionInternal(...):
handled = handle_all_collisions(...)
return handled ? COLLIDED : NO_VALID_POSITION
if transition.curr_cell == null:
return NO_CELL
commit the complete transition
return OK
```
`AdjustPosition` branches on the claimed cell shape. A direct outdoor claim
runs pure `LandDefs::adjust_to_outside` normalization before visible-cell
lookup. An indoor claim first resolves the visible cell and child; only a
resident indoor cell marked `seen_outside` falls back to outdoor
normalization. An absent indoor cell (including the `0xFFFF` sentinel) remains
the exact claimed cell/frame. A map-edge outdoor normalization failure stores
cell zero with the otherwise unchanged frame. The old `max(terrainZ, z)` lift
and nearest-in-Z scan do not occur in this canonical mechanism.
`CheckPositionInternal` calls the complete placement transition. Without the
slide flag, retail accepts the result only when signed
`resolvedX-requestedX <= 0.0500000007`, the same signed Y condition holds, and
the cell is unchanged. Z is not part of that predicate. The resolved origin is
accepted while the requested orientation remains intact.
## Two validators, not one
The similarly named retail helpers have different contracts and stay separate
in the port:
- `validate_placement_transition` is the inner `find_placement_pos` validator.
Any non-OK state from `COLLIDED` through `SLID`, when sliding is permitted,
resets `COLLISIONINFO`; it never retries placement.
- `validate_placement` is the outer initial/final validator. Only `ADJUSTED`
or `SLID`, and only while its retry argument is true, performs one
`placement_insert`; `COLLIDED` neither resets nor retries.
Step-down is disabled only for missiles. For fewer than two spheres retail
first clamps the requested height to half the radius when the sphere diameter
is less than or equal to that height. It then performs one full probe when the
diameter is greater than the resulting height, otherwise two half probes.
Equality belongs to the two-half-probe branch.
## Modern seam
`PhysicsEngine.SetPosition` returns one immutable
`PhysicsSetPositionResult`. `SetPositionError` retains the header values
(`OK=0`, `GENERAL=1`, `NO_VALID=2`, `NO_CELL=3`, `COLLIDED=4`,
`INVALID_ARGS=0x100`) while `PhysicsResidenceDisposition` separately reports
`Committed`, `DeferredCell`, or `Unchanged`. Missing content is therefore
successful-but-deferred, never misreported as a placement failure.
The result carries the complete commit packet: root/cell-local frame,
contact/walkable/water state, sliding and collision normals, stationary-fall
counter, a complete immutable `COLLISIONINFO` snapshot for the real
`handle_all_collisions` callback (including contact/last-contact, sliding,
collision normal, stationary-fall, environment, adjustment, and object
fields), the callback result, and an explicit shadow action. A PhysicsBSP or changed-cell force commit
requests canonical shadow recalculation; a non-BSP transition replaces its
shadows only when the transition produced a nonempty cell array, otherwise it
preserves the prior list. Unchanged force placement changes only the frame.
Core never creates cells synchronously, so retail flag `0x20`
(`DoNotCreateCells`) has no differential loader branch inside this pure
mechanism. Both flag states can only observe already-published immutable cell
content and otherwise return `DeferredCell`. Carrying the flag into exact-cell,
generation-scoped async admission is part of the still-open Slice 4B
adaptation tracked with AD-2; this slice does not claim a dead SpherePath field
as exact behavior.
The existing public `Resolve` compatibility entry remains entirely unchanged
for production movement and zero-delta callers. Its result cannot represent a
successful-but-deferred residence, so hiding `DeferredCell` inside
`ResolveResult.Ok` would corrupt the contract. Slice 4B will route every
authoritative placement family through `SetPosition`, atomically install its
packet, and own exact lost-cell wakeup/commit.
## Automated oracle
`PhysicsSetPositionTests` pins error values, absent/invalid outdoor and indoor
claims, cross-landblock frame normalization, map-edge failure and the `0xFFFF`
sentinel, dummy/authored sphere setup, nonpositive scale, Ethereal seeding,
explicit-only PathClipped, missile step-down, exact equality schedules, both validators,
the late compass sample's float bits, signed no-slide behavior, actual
collision-handler mapping, force-class policy, explicit shadow actions, null
current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering,
failed-probe scratch lifetime, and deferred-scatter stop. The legacy public
Resolve fixture remains unchanged until Slice 4B.
## Slice 4B1 — Runtime residence owner
Slice 4B1 adds the presentation-independent half of the cutover without
changing a production graphical route yet. `RuntimeSetPositionState` accepts
an exact entity/position token before graphical DAT preparation, consumes the
immutable Core result, and commits body, contact, full cell, shadows, object
clock, and Runtime spatial worksets before publishing one ordered placement
delta. The delta carries the exact `RuntimeEntityKey`, session lifetime,
position/spatial/placement versions, adjusted cell, collision generation, and
optional portal-authority shape. A throwing or unavailable host does not roll
simulation back: Runtime republishes the same projection token until the
exact FIFO head is acknowledged. A newer operation changes an already-
published token to `Discard` and increments its projection revision, so an
acknowledgement of the previously observed Place/Withdraw cannot consume an
unseen Discard. It is never silently forgotten. An unacknowledged lost-cell
Withdraw transfers intact to a replacing accepted operation and remains the
FIFO head before that replacement may publish Place.
The successful missing-cell path owns retail's residence shape:
```text
SetPosition -> OK + DeferredCell
retain adjusted Position and the same PhysicsBody/components
clear only Active and suspend the object clock
withdraw Runtime spatial worksets and shadow rows
retain shadow registration and exact authored mover request
append parentless root to (exact cell, collision generation)
arm independent exact-key 25 s deadlines for root + direct children
publish Withdraw
exact cell generation resident + Withdraw acknowledged
re-run SetPosition with retained authored spheres and CurrentCellId=null
atomically install complete result
publish Place
```
Lost-cell membership buckets use retail-shaped append plus swap-remove.
Destruction deadlines use an exact-key hash plus a bounded indexed min-heap,
the allocation-bounded modern equivalent of retail's hash +
`PQueueArray<double>` priority owner. Rearm/removal updates the exact heap node;
there are no stale tombstones. Only committed, current direct children from
the parent-incarnation ordered CHILDLIST participate; unresolved or future
relations cannot inherit a deadline. Parent, pickup, delete, GUID
replacement, newer Position, reset, and disposal cancel the exact incarnation
and use leave-world semantics rather than a wakeable lost entry. The dormant
collision-retirement entry parks non-static parentless indoor roots and, for
complete withdrawal, affected outdoor roots. It performs a complete preflight
and rejects overlap with any active accepted/host-ack-pending placement before
mutating one resident. It then installs every affected canonical lost
residence and operation before publishing the first synchronous Withdraw, so
an observer re-entering for a later root inherits that root's exact pending
Withdraw instead of having its newly accepted placement cancelled by the
retirement loop. 4B2 must quiesce that placement prefix before invoking the
entry in the same transaction that installs host acknowledgements.
Runtime retains the last accepted prepared mover request, including exact
off-center/two-sphere payloads, scale, flags, and step values. A cold resident
with no prepared request still withdraws atomically but remains explicitly in
`AwaitingPreparation`; it cannot wake through an invented empty-sphere shape.
Preparation is cached only after Core accepts it as Committed or DeferredCell.
A rejected/malformed preparation keeps the same accepted token retryable and
cannot replace the last validated mover used by later collision retirement.
Runtime's host boundary rejects only non-finite consumed frame/shape values;
retail-valid oddities such as nonpositive authored scale remain untouched.
Likewise, a non-deferred wake failure retains the withdrawn body, independent
25-second lifetime, and exact operation: invalid arguments return to
AwaitingPreparation and re-index for the next exact generation, while other
world-placement failures also re-index. The last successful DeferredCell
result and adjusted frame remain canonical across the failed attempt. No
failed wake can leave a live entity withdrawn without a Runtime owner. The
graphical/no-window cutover in 4B2 supplies that exact preparation token.
Retail `CPhysicsObj::SetPositionInternal` (`0x00515BD0`) calls
`prepare_to_enter_world` (`0x00511FA0`) only when `this->cell == 0`.
Consequently the physics `update_time` (`PhysicsBody.LastUpdateTime`) and
active bit are reset only on the cellless-to-world edge. Ordinary same-cell or
cross-cell in-world SetPosition preserves the already-consumed physics clock;
entering the lost-cell residence also preserves it until the eventual
cellless wake commit. Runtime pins both sides and does not inherit the older
graphical teleport helper's unconditional timer reset.
The wake timestamp is in the Runtime simulation-time domain, never Unix/UTC:
`GameRuntime` binds its instance `GameRuntimeClock` through the entity/physics
owner, and RetryDeferred samples `SimulationTimeSeconds`. Standalone Runtime
fixtures without a bound game clock retain the accepted command time. This is
a Runtime dependency only; no App delegate enters the owner.
The canonical commit also installs every SetPosition-derived body invariant
before host publication: Contact/OnWalkable/WaterContact, the current contact
plane and slope `GroundNormal`, Sliding plus its normal, and the complete
StationaryFall/Stop/Stuck encoding. Named retail
`CPhysicsObj::SetPositionInternal(CTransition const*)` (`0x00515330`) copies
only the transition's current contact plane/water flag, walkability, sliding
normal/valid flag, and collision state (`0x005153E50x005154FE`). It does not
publish `last_known_contact_plane` or the SpherePath walkable polygon on this
path, so those ordinary-update-only fields are intentionally absent from the
immutable SetPosition result and remain unchanged. A zero expected velocity
version in host preparation preserves the nonzero version captured when the
operation was accepted; an intervening Vector/Movement therefore suppresses
only the stale collision-velocity response. A bodyless cancellation terminates
without fabricating a PhysicsBody or a host Withdraw projection. The two time
domains remain explicit: `PhysicsBody.LastUpdateTime` consumes the instance
simulation clock, while `IRuntimeRemotePlacement.LastServerPositionTime`
remains Unix-UTC receipt time because the remote stale-velocity owner ages it
against `RuntimePhysicsState.UtcNowSeconds`. A deferred wake therefore cannot
make fresh authoritative remote velocity appear years old. Runtime preparation
also applies the existing retail `PositionFrameValidation` before Core or
prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64
attempts; this keeps valid authored retail request shapes while rejecting a
hostile `uint.MaxValue` loop at the authority boundary.
One authority boundary intentionally remains open for 4B2:
- `RuntimePortalPlacementAuthority` validates immutable token shape only;
4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport
sequence, destination, and host acknowledgement before reveal.
The former collision-report boundary is closed by
`RuntimeCollisionReportingState`. Runtime now owns retail's exact-key object
contact table, environment latch, strict ordinary/ethereal expiry, force-end,
static and `ReportAsEnvironment` routing, reciprocal callback eligibility,
missile-state clearing, ordered reentrant dispatch, and the report-result
boolean which distinguishes placement `Collided` from `NoValidPosition`.
Successful SetPosition commits reporting after Contact/OnWalkable and ground
callbacks but before its single physical response and shadow reflood. See
`docs/research/2026-07-31-runtime-set-position-collision-reporting.md`.
### Slice 4B2 checkpoint 1 — public dormant host seam
The first 4B2 checkpoint exposes the dormant receipt owner through
`RuntimePlacementProjectionChannel`. Graphical and no-window hosts can observe
the one ordered placement stream, retry the exact immutable pending receipts,
peek the FIFO head, measure pending debt, and acknowledge only the exact head.
Mutation and retry calls require the current `RuntimeGenerationToken`; a stale
generation, stale revision, reordered token, duplicate acknowledgement, or
reused GUID cannot consume current placement debt. The channel delegates to
`RuntimeSetPositionState` and `RuntimeEntityObjectEventStream`; it owns no
second queue, mirror, or rollback path.
Shared local-controller body adoption is deliberately deferred. A reviewed
prototype that prepared directly on the canonical body was rejected: a
snapshot/rollback lease cannot safely coexist with reentrant SetPosition,
remote/projectile binding, deletion/GUID reuse, owner replacement, object-clock
epoch changes, or disposal. Correct adoption requires either an exclusive
Runtime transaction integrated with every canonical writer, or off-canonical
preparation followed by one validated atomic body/controller publication.
Either choice belongs to the all-route ownership cutover, not this narrow
dormant-seam checkpoint.
This remains a deliberately non-activating checkpoint. Production spawn,
Position, projectile, drop/pickup/parent, and portal routes do not submit to
the dormant SetPosition owner yet. The cutover remains blocked on exact
ordered Setup spheres/scale/step heights/flags/cell-local preparation,
presentation-only rebucketing, and placement-prefix quiescence before
collision retirement. AP-1 and AD-1 remain open until those prerequisites and
every production route land together.
AD-2 remains the explicit async adaptation: collision readiness can publish in
a different frame from retail's blocking load. A failed wake is safely re-
indexed to the next exact generation instead of inheriting retail's
synchronous assumption. When older unbound survivors meet newer entities
already indexed into that future generation, Runtime merges them into one
bucket with the older survivor order first and retains one bucket-order entry.
AP-1 and AD-1 remain open until 4B2 removes the legacy graphical/headless
placement paths.
`RuntimeSetPositionStateTests` pins accepted-before-preparation ownership,
portal-shape rejection, canonical-before-projection ordering, retry and
reentrant discard, cross-landblock adjusted-cell park/wake, same-body
identity, retained contact/water/sliding/velocity, exact generation gating,
authored two-sphere retention, cold preparation, bounded priority-deadline
rearm/cancel, zero-allocation empty ticks, independent ordered direct-child
deadlines, actual collision-admission supersession/invalidation, missing exact
indoor-cell generation rebind/wake, collision demotion/withdrawal preflight,
failed-wake retry, malformed-preparation retry without cache poisoning,
simulation-clock-domain wake, velocity-version preservation, derived body-bit
writeback, immediate remote-velocity survival across the simulation/UTC clock
boundary, invalid cell/frame/quaternion and extreme-scatter rejection before
Core/caching, bodyless cancellation, newer Position/pickup/parent/delete, GUID
reuse, reset, and complete index/node terminal convergence. Existing zero-
allocation collision-generation gates remain unchanged on the no-deferred
fast path.
The warmed immediate commit/ack route currently measures exactly **1,880
managed bytes per operation** in the Release Runtime test host (1,000
iterations after 64 warmups); the regression gate caps it at 2,048 bytes.
This dormant-path result is an explicit 4B2 activation blocker rather than a
claim of allocation-free production readiness: 4B2 must either pool/remove
the operation and projection envelopes or record an approved measured budget
before routing frame-frequency placement through this owner.

View file

@ -1,92 +0,0 @@
# Retail cell availability and containment-root validation — 2026-07-31
## Scope
This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's
atomic streaming-generation work.
The corrected port distinguishes these states:
1. no visible cell payload is loaded;
2. a malformed raw/prepared payload has no containment root;
3. a loaded CellStruct has a valid authored containment root (its physics root
may independently be absent).
Only (3) is published. State (1) remains unavailable and retryable. State (2)
is quarantined atomically so a later valid hydration can retry; it must not
become a world-wide containing cell.
## Installed-data audit
The complete installed EoR catalog and matching prepared package were audited
before choosing this invariant:
- enumerated EnvCells: **729,888**;
- raw: 0 missing EnvCells, 0 missing Environments, 0 missing CellStructs,
0 null `CellBSP` objects, **0 null `CellBSP.Root`**, 729,888 valid roots;
- prepared `acdream.pak`: 0 missing aliases, 0 corrupt payloads,
**0 `ContainmentBsp.RootIndex < 0`**, 729,888 valid roots;
- 6,940 raw EnvCells have zero portals, so the retail portal-pointer guard is
a real catalog path rather than dead defensive code.
There are therefore no root-null record IDs to preserve in either source.
## Retail oracle
`CObjCell::find_cell_list @ 0x0052B4E0` in
`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the
availability gates:
- `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at
`0x0052B50C..0x0052B515`;
- the outdoor branch still calls `CLandCell::add_all_outside_cells` at
`0x0052B53F`, even when that seed lookup returned null;
- the complete growing-array transit walk and containing-cell pick are gated
by `seed != null && num_spheres != 0` at `0x0052B576`;
- each later candidate is independently skipped when its stored cell pointer
is null at `0x0052B58E`.
`CEnvCell::point_in_cell @ 0x0052C300` first returns false when
`this->portals == 0`, then transforms the point and calls
`CCellStruct::point_in_cell`.
`CCellStruct::point_in_cell @ 0x005338F0` calls
`BSPTREE::point_inside_cell_bsp @ 0x005398C0`, which immediately invokes
`BSPNODE::point_inside_cell_bsp(this->root_node, ...)`. The BSP node method at
`0x0053C1F0` dereferences `this` before walking positive children. Only a
missing **positive child below a valid root** is the inside terminal case. A
missing root is not.
## Ported behavior
- `PhysicsDataCache` publishes graph, collision, and prepared records only
after a valid raw/prepared containment root is present. A missing physics
root is retained as a valid non-colliding cell. Invalid containment
publication changes no cache, so later hydration can retry.
- `CollisionTraversal.HasCellContainment` tests `Root` / `RootIndex`.
- Both raw and prepared `EnvCell.PointInCell` paths apply the zero-portals
guard before containment. `CellTransit` applies the same guard to its
`CellPhysics` representation.
- `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells,
but skips the transit walk when the active outdoor seed cannot be resolved
from `CellGraph`. Every later outdoor candidate independently resolves via
`GetVisible` before building transit, so a stale building cannot promote an
object through an unavailable adjacent landcell.
- The existing reflood lifecycle remains the recovery mechanism. Once terrain
or a valid indoor CellStruct publishes, the next reflood walks the authored
portal/building relationships without reconstructing a different rule.
## Gates
Focused tests cover raw/prepared rootless quarantine and valid retry, valid
containment with missing physics, raw/prepared zero-portal parity, indoor and
outdoor seeds, preservation of outside-cell seeding, per-candidate adjacent
landcell availability, suppression of stale-building promotion, and
hydration/reflood recovery. The corrective checkpoint passes:
- focused cell-availability suite: **54/54**;
- Core Release: **4,165 passed / 1 skipped**;
- Runtime Release: **440/440**;
- App Release: **4,002 passed / 3 skipped**;
- complete Release solution: **10,122 passed / 4 skipped**;
- `dotnet build AcDream.slnx -c Release`: **0 warnings / 0 errors**.

View file

@ -1,95 +0,0 @@
# Issue #273 — Holtburg tight-gap support validation
**Date:** 2026-07-31
**Status:** implementation, automated gates, and exact live gate pass
**Scope:** grounded player step-down support at a floor edge beside a static
cylinder
## Captured scene
The reproducible gap is in outdoor cell `0xA9B40032`, between:
- building shell GfxObj `0x01000F69`, placed at
`(158.178, 37.7055, 94.0)` with quaternion
`(w=.939319, x=0, y=0, z=-.343045)`;
- static post `0xCA9B4027`, placed at `(160.173, 34.487, 95.975)`,
represented by its Setup-authored cylinder (`radius=.282`,
`height=5.564`);
- the local player Setup's exact two spheres (`radius=.48`, origins
`z=.475` and `z=1.35`).
The building's supporting ledge terminates at local `x=4`. The first
post-side response moved the player's foot-sphere center to approximately
local `x=4.33`. The full `.48` movement sphere still overlapped the floor, so
the existing step-down path accepted the candidate. Repeated frames then
carried the player around the post and outside the building shell.
The fixture
`tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json` preserves
the installed DAT PhysicsBSP. The replay in
`Issue273HoltburgTightGapReplayTests` uses the captured object placement,
player spheres, static posts, and movement offsets.
## Retail mechanism
The missing rule is not extra collision padding and is not a larger player
sphere. It is retail's second-stage support validation:
1. `CTransition::step_down` (`0x0050B2A0`) performs the ordinary downward
collision probe.
2. After finding a walkable contact plane, an EdgeSlide mover that is not in
StepUp calls `CTransition::check_walkable` (`0x0050AFF0`). The binary
sequence is `test ah,2` at `0x0050B36A`, which is state bit `0x200`
(`EdgeSlide`), followed by the `step_up == 0` test and call at
`0x0050B380`.
3. `CTransition::check_walkable` first calls
`SPHEREPATH::check_walkables` (`0x0050C3E0`).
4. `SPHEREPATH::check_walkables` halves the saved foot-sphere radius and
calls `CPolygon::check_walkable` (`0x00538E60`).
5. If the remembered polygon does not support that smaller sphere,
`CTransition::check_walkable` performs a downward CheckWalkable insertion.
BSP leaves require both `walkable_hits_sphere` and
`CPolygon::check_small_walkable` (`BSPLEAF::hits_walkable`,
`0x0053D670`).
6. If neither check finds support, `CTransition::step_down` rejects the
candidate and the existing edge-response chain handles it.
ACDream already had the small-radius BSP-leaf test, but
`DoCheckWalkable` treated the mere presence of a remembered polygon as
success, and the ordinary `DoStepDown(..., runPlacement:false)` path never
called it. This let a full-radius overlap stand in for actual foot support.
## Port
- `BSPQuery.CheckWalkableSupport` is the shared resolved-polygon form of
retail `CPolygon::check_walkable`.
- `SpherePath.CheckWalkables` implements the retail half-radius remembered
polygon check without mutating canonical sphere state.
- `Transition.DoCheckWalkable` now tests the remembered polygon rather than
treating a non-null polygon as sufficient.
- `Transition.DoStepDown` restores the EdgeSlide/non-StepUp support gate
before the existing placement-policy seam.
There are no location checks, object IDs, guessed radii, widened collision
shapes, or gap-specific tolerances in the production fix.
## Regression impact
The existing #271 staircase-side replay begins with its center `.288 m`
outside a tread whose retail half-radius support boundary is `.24 m`.
Retail may therefore stop that exact candidate. The test now preserves the
original user-visible invariant—never reverse or accelerate downhill—without
requiring forward progress beyond retail's support boundary. The ordinary
continuous staircase replay still requires and achieves forward progress.
## Gates
- issue #273 fixture/replay: 3 passed;
- focused BSP, step-up, edge-slide, #185/#271 family: 42 passed / 1 skipped;
- complete Core tests: 4,111 passed / 2 skipped;
- Release solution build: passed;
- complete Release solution tests: 10,068 passed / 5 skipped.
The user accepted the exact in-client Holtburg gap gate on 2026-07-31: the
gap blocks from the tested approach, and the adjacent movement checks remain
healthy.

View file

@ -1,497 +0,0 @@
# Remaining physics-divergence campaign handoff — 2026-07-31
> **Checkpoint 2 update:** Slice 4B2 prerequisite A, Runtime SetPosition
> collision-report ownership, is implemented in the next checkpoint. Continue
> with the dedicated
> [`runtime SetPosition collision-reporting handoff`](2026-07-31-runtime-set-position-collision-reporting-handoff.md),
> not the prerequisite-A instructions preserved below as historical context.
## Purpose and stopping point
This is the deliberate handoff boundary requested after placement Slice 4B2
checkpoint 1. The repository is stopped before any production graphical or
headless route submits to the canonical Runtime SetPosition owner.
The completed foundation is useful and tested, but the overall campaign is
**not complete**. AP-1 and AD-1 remain narrowed/open. AP-22 and AD-10 remain
open. Do not retire those rows until their exact automated and connected gates
pass.
### Exact workspace
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Handoff code checkpoint: `270f5154`
(`feat(runtime): expose dormant placement receipts`)
- Immediately preceding residence-owner checkpoint: `4c02ac42`
(`feat(runtime): own deferred set-position residence`)
- Pure Core SetPosition checkpoint: `e84a388e`
(`feat(physics): port canonical retail set-position core`)
- No upstream is configured for this worktree branch.
- Remotes:
- `origin`: `https://git.snakedesert.se/erik/acdream.git`
- `github`: `git@github.com:eriknihlen/acdream.git`
The handoff was written in this same worktree. The next agent should continue
there rather than creating a different worktree unless the user explicitly
requests it.
## Worktree hygiene
The worktree intentionally reports unrelated modifications. Preserve them.
Never use `git add -A`, `git reset --hard`, or checkout/revert commands against
these paths.
At the checkpoint, `AGENTS.md` has a real unrelated content diff. The following
paths report modified due to existing line-ending/stat noise but have no
content diff against the index:
- `src/AcDream.App/Input/PlayerModeController.cs`
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`
- `src/AcDream.App/World/LiveEntityRuntime.cs`
- `src/AcDream.Core/Physics/CellArray.cs`
- `src/AcDream.Core/Physics/PhysicsBody.cs`
- `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`
- `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`
- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs`
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
- `tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs`
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`
- `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`
- `tools/A8CellAudit/A8CellAudit.csproj`
Before every commit, stage exact paths and inspect:
```powershell
git diff --check
git diff --cached --check
git diff --cached --stat
git status --short
```
## What is complete
### Campaign baseline and issue #273
- `c24bc571` — retail StepDown support-radius behavior for tight gaps.
- `10b55d74` — tight-gap controls and diagnostics.
### Retail retry and edge/StepDown dispatcher
- `e5f855ac` — nested per-cell collision retries.
- `67d1e9b3` — refreshed-cell retry state.
- `4ca7230b` — retained cell across inner retries.
- `c559c48d` — retail edge-response ordering.
- `4fbd93ec` — edge-slide stop semantics.
- `1fd5da67` — StepDown placement validation.
- `acec33ec` — StepDown probe state.
- `75b6f6b6` — Path-6 collision response.
- `d3c0d9ec` — TS-4 production chronology gate.
These retire AP-3, AP-4, AP-5, AD-53, AD-54, and TS-4 under the tests and
research already recorded in the divergence register.
### Exact cell availability and atomic collision generations
- `7716c2ee` — retail cell-availability semantics.
- `3e0f3b62` — containment-root validation.
- `be94bc9b` — atomic collision-generation activation.
- `d94145e6` — seal before activation.
- `6b28ff99` — starvation-free activation.
These retire AD-3, AD-4, and AD-6. The active collision world remains visible
until a complete replacement generation is atomically committed.
### Canonical SetPosition Core and Runtime residence owner
- `e84a388e` ports the pure Core SetPosition transaction.
- `4c02ac42` adds `RuntimeSetPositionState`, including:
- exact accepted operation ownership;
- canonical body/contact/cell/shadow/workset commit;
- authored mover retention;
- exact-cell and collision-generation wake;
- 25-second root/direct-child lost-cell lifetime;
- bounded indexed deadline structures;
- revisioned ordered Withdraw/Place/Discard host receipts;
- cancellation/GUID-reuse/reset/disposal convergence;
- structural cell/quaternion validation and bounded scatter work.
- `270f5154` adds `RuntimePlacementProjectionChannel`, a public,
generation-gated host seam over the one existing receipt owner:
- subscribe to ordered immutable receipts;
- peek the exact FIFO head;
- retry pending receipts without recommitting Runtime state;
- acknowledge only the exact current FIFO-head token;
- observe pending receipt debt.
The channel owns no second queue and has no App or Headless production
consumer. A source guard pins that dormancy. This is intentional.
### Validation at the stopping point
The final channel-only checkpoint passed:
- Release solution build: 0 errors, 18 existing warnings.
- Complete Release suite: 10,279 passed, 4 skipped, 0 failed.
- Runtime SetPosition focused tests: 47/47.
- App dormancy/ownership guards: 3/3.
- `git diff --check`: clean.
- Retail-conformance re-review: clean.
- Architecture/adversarial re-review: clean.
## Rejected prototype — do not resurrect it
A prototype made graphical and headless `PlayerMovementController` instances
borrow the canonical `RuntimeEntityRecord.PhysicsBody` immediately and used a
snapshot/rollback lease to recover from late construction failures. It was
fully removed before `270f5154`.
The design was rejected because it was failure-atomic only without
reentrancy. While the lease was open, a nested SetPosition, remote/projectile
bind or update, deletion/GUID replacement, object-clock epoch change, or
disposal could establish newer authority. The outer rollback could then erase
that newer commit or detach a body already used by another canonical owner.
The next implementation must use one of these complete solutions:
1. A Runtime-owned exclusive/versioned controller/body publication
transaction respected by **every** canonical body writer, binding path,
SetPosition operation, clock epoch transition, deletion, reset, and
disposal; or
2. Off-canonical preparation followed by one validated atomic Runtime commit
that publishes the prepared controller/body relationship without copying
stale state over newer authority.
Because every writer must participate, this belongs to the atomic production
route cutover. Do not reintroduce a local snapshot lease or a Commit method
that merely checks the body reference at the end.
## What remains — required execution order
### Slice 4B2 prerequisite A — real collision-report ownership
`RuntimePhysicsState.HandleSetPositionCollisions` still returns `false`.
Retail `CPhysicsObj::SetPositionInternal` (`0x00515330`) returns the real
per-object report/tracking result. Establish the Runtime owner for that result
and return it exactly. Do not restore the former environment/object-presence
guess.
Required tests:
- report/no-report objects;
- collided object set ordering and lifetime;
- reentrant deletion/reset;
- graphical/headless equality;
- no report state surviving GUID reuse.
### Slice 4B2 prerequisite B — exact authored mover preparation
Every production preparation must pass the full SetPosition request using:
- Setup's exact ordered authored spheres;
- exact scale, including valid zero/presence semantics;
- exact StepUp and StepDown heights;
- exact flags;
- exact cell-local frame and orientation;
- current position/vector/state authority versions.
Do not reconstruct a cylinder from visual radius/height, clamp a positive
scale, use the projectile mover as a generic object fallback, or pre-mutate
`FullCellId`, `PhysicsBody`, `WorldEntity`, App buckets, or shadows.
### Slice 4B2 prerequisite C — atomic local controller/body publication
Implement the complete transaction described in the rejected-prototype note.
Graphical and no-window controllers must end with the exact same Runtime body,
but construction cannot expose or mutate canonical state before the validated
atomic commit.
Adversarial gates must include:
- nested construction;
- reentrant SetPosition;
- remote and projectile binding/update;
- deletion and same-GUID new incarnation;
- projection-owner replacement;
- object-clock epoch change;
- reset and disposal;
- commit and rollback after replacement;
- late graphical camera/shadow/host failure;
- late headless prepared-collision failure.
### Slice 4B2 prerequisite D — presentation-only host projection
Add one graphical and one headless `IRuntimePlacementObserver` using
`GameRuntime.Placements`.
Host receipt rules:
- `Withdraw`: remove render/spatial presentation, picking, radar, audio, and
targeting while retaining logical Runtime ownership.
- `Place`: project only the immutable Runtime-committed frame, then
acknowledge the exact token.
- `Discard`: discard the older projection revision, then acknowledge it.
- A host exception or unavailable backend does not roll Runtime back; retry
the same FIFO head.
`LiveEntityRuntime.RebucketLiveEntity` must become presentation-only. Its
current `CommitRebucket` call is a second spatial authority and must be removed
as part of the same cutover.
### Slice 4B2 prerequisite E — collision-prefix quiescence
Before landblock collision demotion, removal, or replacement:
1. quiesce the prefix;
2. drain/ack the existing placement receipt prefix;
3. call `RuntimeSetPositionState.ParkCollisionResidents`;
4. commit/withdraw the collision generation atomically;
5. wake only exact cell+generation residents.
Cover `LandblockPhysicsPublisher.DemoteToTerrain`, `RemoveLandblock`,
replacement commit, and the headless collision-retirement path. No partially
observable collision generation is allowed.
## Production route cutover
Cut routes only after all prerequisites above are present. The canonical chain
for every route is:
```text
wire acceptance
-> BeginAcceptedPlacement exact token
-> exact DAT/Setup preparation
-> Runtime SetPosition canonical commit or deferred residence
-> immutable host projection receipt
-> exact host acknowledgement
```
### 1. Initial login and CreateObject
Current duplicate authority:
- `DatLiveEntityProjectionMaterializer.MaterializeProjection` immediately
positions/rebuckets the world entity.
- `PlayerModeController.BuildControllerAndCamera` builds its own body and runs
`Resolve`/`ResolvePlacement`.
- Headless performs its own initial resolve/placement/body construction.
Required order:
1. register identity cellless;
2. begin initial/remote-create placement before hydration;
3. load exact Setup mover;
4. prepare the atomic Runtime controller/body relationship;
5. submit canonical SetPosition;
6. publish presentation only from `Place`;
7. acknowledge, then enable player mode/simulation.
Tests: outdoor/indoor login, unavailable destination then exact-generation
wake, malformed or delayed Setup, one body identity, one enter-world clock
reset, no early visible entity, graphical/headless identical snapshots.
### 2. Local ForcePosition
Delete the placement authority in `LocalForcePositionTransaction` and the
direct `BlipPosition`/pre-commit acknowledgement in
`LiveEntityNetworkUpdateController.OnPosition`.
Required order: accept timestamp and preserve heading; begin
`LocalAuthoritative`; canonical SetPosition; host `Place` acknowledgement;
then send the outbound Position acknowledgement. A missing destination cell
must not acknowledge ACE early.
Tests: same/cross cell, preserved heading/velocity, missing-cell wake,
reentrant newer Position, stale host ack, exactly one outbound ack.
### 3. Portal transit and materialization
Remove placement authority from `LocalPlayerTeleportPlacement.Place` and its
direct resolve/controller/world-entity/rebucket/spatial mutations.
Bind a Runtime portal-placement authority to the active
`RuntimeWorldTransitState` reveal generation, teleport sequence, exact
destination cell, and placement token. Readiness permits submission only.
Materialization and simulation release happen only after canonical commit,
host projection, and exact acknowledgement. Cancellation/replacement produces
`Discard`; a stale generation/sequence/cell/token can never reveal.
Tests: `/ls`, spell recall, ordinary portal, same-location revisit, missing
destination, cancelled/replaced reveal, host throw/retry, no early world reveal
or LoginComplete.
### 4. Remote CreateObject and Position
Delete `RemoteTeleportController`, `RemoteTeleportPlacement`, their pending
dictionary/rollback/lost-cell ownership, and pre-placement
`WorldEntity.SetPosition`/rebucket calls.
Preserve retail `MoveOrTeleport` classification:
- fresh Teleport timestamp or cellless body: teleport hook then SetPosition;
- ordinary nearby grounded update: interpolation remains;
- distant update: stop interpolation then SetPosition.
Accept the timestamp, begin the exact token before hydration/body/App changes,
unparent first, run the retail teleport hook when required, submit the exact
mover, project after Runtime commit, then re-arm constraints.
Tests: visible/hidden/parented CreateObject, first Position, Teleport timestamp,
near interpolation, >96 m far placement, unloaded indoor destination, racing
velocity, delete/GUID reuse during host callback.
### 5. Projectile authoritative create/corrections
Remove authoritative placement from App `ProjectileController` and the direct
SnapToCell/cell/shadow commit in `RuntimeProjectilePhysicsUpdater`.
Use `ProjectileAuthoritative` with the same Runtime body and exact projectile
Setup sphere for initial create and authoritative corrections. Preserve
prediction/component/effect identity. Do not route ordinary per-quantum
projectile integration through SetPosition.
Tests: arrow, bolt, spell projectile, mid-flight correction, unloaded cell,
landblock crossing, delete during ack, no duplicate body/projectile/effect.
### 6. Drops and unparent-to-world
`InventoryWorldDropProjectionController.TryRecoverUnknownPosition` may create
the logical object, but it must enter the same canonical create-placement
transaction. Do not expose a stale source position or replay create-time
effects.
Tests: whole item, split stack, new GUID, second drop position, attached child
becoming a world root, unavailable destination, newer Position while waiting.
### 7. Pickup, Parent, and Delete
Runtime hooks already exist in `RuntimeEntityObjectLifetime`, but the route
cutover must ensure pickup/parent/delete cancel the exact active
placement/lost-cell family first and publish `Discard`/`Withdraw` before the
later entity/inventory delta.
Tests: pickup during preparation/deferred residence, parent during pending
withdrawal, delete during host callback, GUID reuse, reset/disposal ownership
convergence.
### 8. Headless parity
Delete the independent resolve/placement/direct SetPosition and Blip logic in
`HeadlessSessionWorldProjection`. Headless must prepare/commit/ack through the
same Runtime operations as graphical presentation. Portal completion also
waits for the exact placement acknowledgement.
Tests: byte-identical login, ForcePosition, portal, missing-cell wake,
reconnect, and teardown snapshots.
## AP-22 — retail-authored collision shapes
After AP-1/AD-1 production cutover is stable:
- Make `ShadowShapeBuilder` the single Core authority for prepared Setup
primitives.
- Preserve authored cylinder order.
- If no cylinders exist, preserve authored spheres as spheres.
- Mixed data uses retail cylinder-first precedence.
- A truly shapeless Setup emits no world shadow.
- Remove `Setup.Radius/Height` collision synthesis, `Radius * 2` height guesses,
and sphere-to-cylinder coercion.
- Cut graphical static, headless static, and live-entity publication over
together.
- Do not alter transition dummy spheres, sticky/range radius, or projectile
mover shapes.
Automated gates: raw/prepared parity, cylinder order, sphere-only, mixed,
shapeless, scale, graphical/headless equality, representative installed DATs,
and dropped/portal/sign/door behavior.
## AD-10 — canonical remote slope projection
After AP-22:
- Prove remote movement uses the full `ResolveWithTransition` sweep.
- Remove terrain-normal preprojection from `RemoteMotionCombiner`.
- Remove Runtime terrain-normal sampling calls and delete the sampler if no
longer used.
- Let `CTransition::adjust_offset` project against the retained actual contact
plane.
- Preserve interpolation queues, correction replacement, Hidden behavior,
network cadence, and graphical/headless parity.
Tests must deliberately make terrain normals disagree with BSP/prop contact
normals, then cover uphill/downhill motion, seams, stairs, jumping, landing,
queue-empty/head-reached boundaries, and two-client observation.
## Closeout gates
Do not mark the campaign complete from narrow tests alone.
Automated:
```powershell
dotnet build AcDream.slnx -c Release
dotnet test AcDream.slnx -c Release --no-build --nologo
```
Also run every focused fixture named in the campaign plan: #273 tight gap,
#271 stair side, #269 slope, #265 landing, #185 stairs, #137 sliding normal,
#116 head collision, roof/cellar wedge, missing-cell, generation replacement,
GUID reuse, graphical/headless parity, and allocation/quiescence gates.
Connected/visual:
- login and portal arrival at outdoor, indoor, dungeon, stair-lip, and world
edge locations;
- repeated `/ls`, spell recall, ordinary portals, same-location revisit, and
reconnect;
- no early world reveal, outdoor demotion, floor snap, terrain-Z lift, or void;
- tight gaps, stairs, steep roofs, ledges, doors, crowds, shallow water, and
landblock seams;
- dropped objects, portals, signs, doors, and shapeless decorations;
- two-client uphill/downhill movement and sloped props;
- headless/graphical trace equality and graceful zero-residue teardown.
Only then retire AP-1, AD-1, AP-22, and AD-10, update the architecture,
divergence register, campaign/roadmap/milestones, research notes, durable
memory, `CLAUDE.md`, and `AGENTS.md`, and record final rollback SHAs.
## Review procedure for every remaining behavior commit
1. Implement one bisectable mechanism and run focused tests.
2. Run a retail-conformance reviewer against named retail symbols/addresses.
3. Run an architecture/adversarial reviewer against reentrancy, stale
sequences, malformed data, GUID reuse, streaming replacement, host failure,
reset, and disposal.
4. Fix every confirmed finding at its root cause.
5. Re-run the same reviewers until clean.
6. Run Release build plus the complete Release test suite.
7. Update the divergence/docs in the same behavior commit.
8. Stage exact paths only and commit.
## Rollback points
Newest first:
```powershell
git revert 270f5154 # dormant public placement receipt channel
git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner
git revert e84a388e # pure Core retail SetPosition transaction
```
Earlier campaign commits are individually bisectable and listed in the
completed sections above. Revert only the responsible mechanism; do not
restore the rejected snapshot lease or revive legacy compensation elsewhere.
## First action for the next agent
1. Read this file completely.
2. Read `docs/research/2026-07-31-canonical-set-position.md` and the AP-1/AD-1
rows in `docs/architecture/retail-divergence-register.md`.
3. Confirm `HEAD` contains `270f5154` in the exact worktree above.
4. Confirm only `AGENTS.md` has a real unrelated unstaged diff.
5. Implement prerequisite A (real Runtime collision-report ownership) as its
own reviewed commit.
6. Then design prerequisites B/C together so exact mover preparation and the
atomic controller/body transaction cannot create another partial ownership
state.

View file

@ -1,245 +0,0 @@
# Runtime SetPosition collision-report ownership handoff - 2026-07-31
## Purpose and exact stopping point
This handoff records placement Slice 4B2 checkpoint 2: the isolated Runtime
owner for retail SetPosition collision tracking and report-result semantics.
The checkpoint intentionally stops before authored mover preparation, shared
local-controller body publication, graphical/headless placement projection,
collision-prefix quiescence, or any production SetPosition route cutover.
Production behavior is therefore unchanged by this checkpoint. The new owner
is populated only by the dormant `RuntimeSetPositionState` and focused tests.
AP-1 and AD-1 remain narrowed/open; AP-22 and AD-10 remain open.
## Exact workspace
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Starting checkpoint: `ec627c13`
(`docs(physics): hand off remaining divergence campaign`)
- This handoff belongs to the same behavior commit as the implementation.
- No upstream is configured for this worktree branch.
- Remotes:
- `origin`: `https://git.snakedesert.se/erik/acdream.git`
- `github`: `git@github.com:eriknihlen/acdream.git`
Continue in this worktree unless the user explicitly requests otherwise.
`AGENTS.md` has an unrelated pre-existing content diff and must not be staged,
restored, or rewritten as part of this checkpoint. Several other paths report
line-ending/stat noise without a content diff; stage only the exact paths
listed in the final commit.
## Retail oracle
The complete readable oracle is
[`2026-07-31-runtime-set-position-collision-reporting.md`](2026-07-31-runtime-set-position-collision-reporting.md).
The named-retail anchors are:
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
- `CPhysicsObj::report_object_collision` `0x00513060`
- `CPhysicsObj::track_object_collision` `0x00513F10`
- `CPhysicsObj::report_collision_start` `0x00513FD0`
- `CPhysicsObj::report_collision_end` `0x00514620`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
- successful `CPhysicsObj::SetPositionInternal(CTransition const*)`
`0x00515330`
- `CPhysicsObj::leave_world` `0x005155A0`
- placement failure in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
The source is `docs/research/named-retail/acclient_2013_pseudo_c.txt`; the
struct authority is `docs/research/named-retail/acclient.h`.
## What this checkpoint implements
`RuntimeCollisionReportingState` is the sole per-session owner of:
- one environment-collision latch per exact `RuntimeEntityKey`;
- one ordered object-contact table per exact owner incarnation;
- retained peer server GUID, touch time, and ethereal-at-touch state;
- static and `ReportAsEnvironment` routing;
- asymmetric `IgnoreCollisions` and reciprocal `ReportCollisions` eligibility;
- strict ordinary `age > 1.0` and ethereal `age > 0.0` expiry;
- force-end-before-callback mutation for reentrant safety;
- missing-peer self-only end reports without resolving a later GUID reuse;
- exact `Missile | AlignPath | PathClipped` clearing on the canonical record,
borrowed body, retained shadow state, and mutation version;
- a monotonic immutable report FIFO with observer-failure isolation;
- the retail callback-eligibility boolean used by failed placement to choose
`Collided` versus `NoValidPosition`;
- terminal ownership diagnostics and deterministic session/disposal cleanup.
Successful dormant SetPosition commits contact, water/walkable and ground
edges first, runs reporting next, applies physical response once, and then
refloods the shadow. An intervening Vector or Movement update suppresses only
the stale physical response; it does not erase collision tracking or reports.
Failed placement always supplies retail's `previousContact = false` and
`previousOnWalkable = false`, reports once, applies its one response pass, and
maps the report result exactly.
Hidden, teleport/withdrawal, deletion, session reset, and disposal use distinct
lifetime edges. Leaving the world force-ends the departing owner's table but
retains its environment latch and incoming peer records. Destruction then
forgets only the departing owner state. Other owners retain exact-key contacts
until their own expiry/force pass and can emit a missing-target end using the
preserved server GUID. Hidden and session-clear paths force-end while the old
report flags and bodies are still eligible, before state/reset teardown.
## Architectural boundaries
- Runtime owns all canonical collision-report state and report-result logic.
- Core exposes only the exact successful SetPosition ordering seam and the
retained-shadow collision identity required by Runtime.
- App and Headless gain no report table, queue, heuristic, or production
placement consumer.
- Reports are presentation-free and keyed by exact Runtime identity.
- Network/update callbacks may re-enter, but every later mutation revalidates
current identity, body, and the relevant authority version.
- Physical-response velocity authority is deliberately separate from report
authority, matching retail's ordering without overwriting a newer vector.
## Validation and independent review
The saved final diff passed:
- combined focused Runtime collision-report and SetPosition tests: 76/76;
- complete Runtime project: 562/562;
- graphical/headless Runtime-physics ownership and dormancy guards: 4/4;
- focused Core SetPosition/contact/response ordering tests: 29/29;
- complete Core project: 4,224 passed / 1 intentional skip;
- from-source Release solution rebuild: 0 errors and 21 pre-existing test-
project nullable/analyzer warnings; this checkpoint introduces none;
- complete Release solution: 10,309 passed / 4 intentional skips;
- warmed steady-contact refresh: 0 managed bytes across 10,000 calls;
- warmed immediate dormant SetPosition commit: still below the existing
2,048-byte-per-operation ceiling, with no new captured-delegate cost;
- architecture/adversarial re-review: clean after fixing Hidden/session/delete
reentrancy, stale shadow-state authority, allocation churn, and batch cost;
- retail-conformance re-review: clean against every named address above.
The final retail re-review found and closed two last ordering defects before
sign-off: object collision now snapshots the mover's Missile bit before the
source callback and, when that snapshot was set, unconditionally masks the
current `Missile | AlignPath | PathClipped` bits afterward. Thus an ordinary
callback-added Missile is retained when the mover was not previously a missile,
but a callback which clears Missile and re-adds path bits cannot evade the
pre-gated retail mask. Environment collision retains retail's post-callback
current-Missile test. Successful SetPosition now
publishes reports before installing the new stationary-fall counter, applies
the physical response next, installs StationaryFall/Stop/Stuck transient bits
after response, and only then refloods the shadow.
The host guard reads both production source trees. It proves App and Headless
borrow `GameRuntime.EntityObjects.Physics`, declare no second collision table
or return heuristic, and still contain no placement-channel consumer. No
connected/live gate is required for this dormant checkpoint because no
production route can populate or publish the new report owner.
## Exact implementation and test paths
The behavior commit containing this handoff changes exactly these ten code and
test paths:
- `src/AcDream.Core/Physics/PhysicsObjUpdate.cs`
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs`
- `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`
- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs`
- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`
- `tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs`
The same commit synchronizes the architecture, divergence register, canonical
SetPosition research, roadmap, milestones, project memory, prior campaign
handoff pointer, retail-oracle note, and this detailed handoff. `AGENTS.md` and
the pre-existing line-ending/stat-noise paths are deliberately excluded.
## Remaining work - required order
### 1. Exact authored mover preparation - complete 2026-08-01
The dormant preparation contract is implemented and independently reviewed.
It binds the Runtime-owned accepted frame and exact Setup DID, preserves the
ordered authored spheres and scale/step semantics, seals the returned command,
and forces stale deferred residents through exact re-preparation without
pre-mutating canonical state. See
[`2026-08-01-runtime-set-position-authored-mover-preparation.md`](2026-08-01-runtime-set-position-authored-mover-preparation.md).
### 2. Atomic local-controller/body publication - next
Prepare off-canonical, then perform one Runtime-validated atomic transaction
which publishes the exact same body to graphical and no-window controllers.
Every body writer, remote/projectile binding, SetPosition operation, clock
epoch, deletion, reset and disposal path must participate. Do not resurrect
the rejected snapshot/rollback lease documented in the prior handoff.
### 3. Presentation-only host projection
Implement graphical and headless observers over the existing dormant placement
receipt channel. Withdraw removes presentation/spatial consumers while
retaining Runtime identity; Place projects only the immutable committed frame;
Discard retires the older revision. Host failure retries the exact FIFO head
and never rolls Runtime back.
### 4. Collision-prefix quiescence and atomic route activation
Park SetPosition residents before retiring their collision prefix, publish the
complete replacement generation, wake exact matching residents, and cut every
spawn/Position/portal/projectile/drop/pickup/parent/delete route over together.
Only then may AP-1 and AD-1 retire.
### 5. Remaining campaign slices
- Port retail-authored object collision shape precedence and retire AP-22.
- Remove remote terrain-normal preprojection and let the transition resolver
use the retained contact plane, retiring AD-10.
- Run the full automated and connected matrix, update all ledgers, and close
the remaining physics campaign only with direct evidence.
## Rollback
This checkpoint is one bisectable commit. Revert the commit containing this
file to remove collision-report ownership without disturbing the earlier
SetPosition residence and receipt-channel checkpoints. Do not revive the old
collision-presence guess or the rejected body snapshot lease.
Because a Git commit cannot embed its own final hash, resolve the exact
checkpoint and revert command without ambiguity using:
```powershell
$checkpoint = git log -1 --format=%H -- `
docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md
git show --stat $checkpoint
git revert $checkpoint
```
Earlier rollback points remain:
```powershell
git revert 270f5154 # dormant public placement receipt channel
git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner
git revert e84a388e # pure Core retail SetPosition transaction
```
## Resume procedure
1. Continue in
`C:\Users\erikn\.codex\worktrees\af5e\acdream` and verify
`git branch --show-current` reports `codex/port-claude-agents`.
2. Resolve the exact checkpoint with the `git log` command above and confirm
it is the current `HEAD` before starting the next behavior slice.
3. Read `AGENTS.md`, `docs/architecture/acdream-architecture.md`, this file,
the collision-report oracle, the canonical SetPosition research, and the
prior remaining-campaign handoff completely.
4. Run `git status --short`. Preserve the unrelated `AGENTS.md` content diff
and every documented line-ending/stat-noise path. Never stage by blanket.
5. Begin only **Exact authored mover preparation**, the first remaining item
above. Do not activate production routes, retire AP-1/AD-1, begin AP-22 or
AD-10, or resurrect the rejected body snapshot/rollback lease.
6. Use exact-path staging and rerun the matching focused projects,
`dotnet build AcDream.slnx -c Release --nologo`, and
`dotnet test AcDream.slnx -c Release --no-build --nologo` before the next
reviewed checkpoint.

View file

@ -1,182 +0,0 @@
# Runtime SetPosition collision-report ownership
**Scope:** placement/streaming Slice 4B2 prerequisite A only. This closes the
missing Runtime owner for retail collision tracking and the boolean returned by
`CPhysicsObj::handle_all_collisions`. It does **not** activate any graphical or
headless production SetPosition route.
## Named-retail oracle
Primary sources:
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
- `CPhysicsObj::report_object_collision` `0x00513060`
- `CPhysicsObj::track_object_collision` `0x00513F10`
- `CPhysicsObj::report_collision_start` `0x00513FD0`
- `CPhysicsObj::report_collision_end` `0x00514620`
- `CPhysicsObj::handle_all_collisions` `0x00514780`
- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330`
- `CPhysicsObj::leave_world` `0x005155A0`
- placement failure path in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
- `CPhysicsObj::CollisionRecord`, `EnvCollisionProfile`,
`ObjCollisionProfile`, and `AtkCollisionProfile` in
`docs/research/named-retail/acclient.h`
The source text is
`docs/research/named-retail/acclient_2013_pseudo_c.txt`. The addresses above
are the behavioral authority; the older unnamed chunks remain fallback only.
### Environment reporting
```text
report_environment_collision(meInContact):
reported = false
if !colliding_with_environment:
if self.ReportCollisions && self.weenie != null:
DoCollision(EnvCollisionProfile(self.velocity, meInContact))
reported = true
colliding_with_environment = true
if self.Missile:
self.state &= ~(Missile | AlignPath | PathClipped)
return reported
```
The latch is independent of callback eligibility. An object with no collision
callback still latches its environment contact, and a repeated environment hit
returns false. Retail has no environment-end callback. `leave_world` does not
clear this latch; the next `handle_all_collisions` call re-arms it only after a
non-environment frame.
### Object reporting and tracking
```text
track_object_collision(other, meInContact):
if other.Static:
return report_environment_collision(meInContact)
record = { touched_time = PhysicsTimer.curr_time,
ethereal = other.Ethereal }
existed = collision_table.clobber(other.id, record)
if existed:
return false
return report_object_collision(other, meInContact)
```
The table insert/refresh precedes callbacks. Duplicate contacts refresh their
time but never replay a start callback. DAT/static classification and physics
state come from the exact shadow object which produced the collision; object-ID
presence is not a valid substitute.
`report_object_collision` first maps `ReportAsEnvironment` to the environment
path. Otherwise:
- the mover reports only when the other object is not `IgnoreCollisions` and
the mover has `ReportCollisions` plus a weenie;
- a mover which had Missile set before the source callback unconditionally
masks its current `Missile | AlignPath | PathClipped` bits after striking a
non-ignored object, even when the callback cleared Missile but re-added path
bits; when pre-callback Missile was clear, callback-added Missile is retained;
- the reciprocal report occurs only when the other has `ReportCollisions`, the
mover is not `IgnoreCollisions`, and the other has a weenie;
- the return is true when at least one callback is attempted. It is never a
collision-presence boolean.
### Expiry and end reporting
`report_collision_end(force)` removes records before dispatching callbacks.
This ordering is required for safe reentrancy.
```text
ordinary record: remove when age > 1.0, or force
ethereal record: remove when age > 0.0, or force
```
Equality remains alive. A still-resolvable non-`ReportAsEnvironment` peer may
receive reciprocal collision-end callbacks. When the peer no longer resolves,
the owner can still receive its self-only end using the stored retail object
ID. A later incarnation must never satisfy the old contact record.
### `handle_all_collisions` and SetPosition ordering
```text
handle_all_collisions(info, previousContact, previousOnWalkable):
reported = false
for other in info.collidedObjects, in encounter order:
reported |= track_object_collision(other, previousContact)
report_collision_end(force = false)
if environment latch is already set:
latch = info.collided_with_environment
else if info.collided_with_environment
|| (!previousOnWalkable && self.OnWalkable):
reported |= report_environment_collision(previousContact)
apply retail collision velocity/stationary response
return reported
```
Successful `SetPositionInternal(CTransition const*)` commits the resolved
cell/frame, Contact/WaterContact/OnWalkable state, and HitGround/LeaveGround
edge before `handle_all_collisions`; it ignores the returned boolean and only
then replaces/refloods shadows. Collision reports observe the old stationary-
fall state; the new counter is installed before physical response, while the
StationaryFall/Stop/Stuck transient bits are replaced after response and before
shadow reflood. The placement failure path calls
`handle_all_collisions(info, false, false)` and maps true to
`SetPositionError::Collided` (`4`) and false to `NoValidPosition` (`2`).
Consequently acdream must keep report/tracking separate from the physical
response: failed placement runs both once, while successful Runtime commit
runs reporting between the contact/ground commit and shadow reflood without
double-applying velocity response.
## Runtime ownership contract
The implementation is presentation-free and belongs to the per-session
`RuntimePhysicsState` graph. Its invariants are:
- owner and peer identities are exact `RuntimeEntityKey` values, not server
GUID or local ID alone;
- each tracked record retains the peer server GUID, touch time in the Runtime
simulation-clock domain, and ethereal-at-touch bit;
- collided IDs and authored/static ownership are admitted through the exact
retained `ShadowObjectRegistry` registration which produced the collision;
every dynamic Static/Ethereal/Ignore/ReportAsEnvironment decision then reads
the current canonical `PhysicsBody.State`, never a stale shadow snapshot;
- immutable reports preserve encounter order and dispatch through a retained,
reentrancy-safe FIFO;
- callback exceptions are isolated, while the retail report-result boolean is
determined by callback eligibility and does not depend on subscribers;
- every callback boundary revalidates the exact record/body/authority before
any later canonical mutation;
- force-end mutates the complete expired set before publishing ends; exact-key
admission guards prevent callback reentry from recreating a leaving owner,
and session teardown blocks the whole owner batch before its first callback;
- one source lifetime token covers a complete precollected end batch, so a
callback-accepted delete stops every later peer report even while teardown
sidecars remain resolvable;
- lifetime forget, session reset, and disposal cannot donate state to GUID
reuse;
- terminal ownership diagnostics include contact/report state and converge to
zero;
- graphical and no-window hosts borrow the same Runtime owner. No host owns a
second collision table or report-result heuristic.
The warmed steady-contact refresh path allocates zero managed bytes. Expired
contact storage is allocated lazily only after the first actual expiry, and
session-batch teardown is linear in owner count.
## Deliberately deferred
The canonical SetPosition owner remains dormant in production. The following
belong to later 4B2 commits and are not part of this checkpoint:
- exact ordered Setup spheres, authored scale and step-height preparation;
- the atomic shared local-controller body transaction;
- presentation-only rebucketing and placement-prefix quiescence;
- graphical/headless spawn, Position, portal, projectile, drop, pickup,
parent, and delete route cutover.
AP-1 and AD-1 therefore remain open, narrowed only by removal of the
collision-report prerequisite.

View file

@ -1,302 +0,0 @@
# Runtime initial Create residence handoff - 2026-08-01
> **Status:** this remains the `38fd4b8d` residence-foundation history. The
> completed inbound-admission checkpoint and current continuation boundary are
> recorded in
> [`2026-08-01-runtime-initial-placement-admission-handoff.md`](2026-08-01-runtime-initial-placement-admission-handoff.md).
## Purpose and exact stopping point
Commit `38fd4b8dc952236d4b98518c67335026c7815656` adds the dormant Runtime
transaction which retains an entity's initial authored CreateObject placement
until canonical SetPosition succeeds and the ordered remainder of the Create
packet can be adopted. It does not yet cut the production App/Headless Create
route over, so AP-1 and AD-1 remain open.
This is the deliberate clean handoff requested by the user. In plain terms,
Runtime now has a tested holding area for a newly created world object while
its exact collision placement is being resolved. The object cannot become
half-visible, consume later position packets, or be silently replaced during
that interval. The next model starts at the executor/cutover boundary; it does
not need to repair or redesign this ownership transaction.
Do not start production cutover from an earlier checkpoint. Do not call this
campaign complete: AP-1, AD-1, AP-22, and AD-10 remain open.
## Exact workspace and Git state
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Code checkpoint: `38fd4b8dc952236d4b98518c67335026c7815656`
- Immediately preceding host-staging checkpoint: `74103f75`
- No push or merge is part of this stopping point.
The worktree intentionally contains unrelated user changes/stat noise. Do not
stage, restore, normalize, or rewrite these paths as part of the continuation:
- `AGENTS.md` (real unrelated content change);
- `src/AcDream.App/Input/PlayerModeController.cs`;
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`;
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`;
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`;
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`;
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`;
- `tools/A8CellAudit/A8CellAudit.csproj`.
The paths after `AGENTS.md` currently have no content diff and are reported
because of pre-existing line-ending/stat noise. Always stage exact paths;
never use `git add -A`.
## Placement checkpoint chain
The current mechanism was built as bisectable commits. The directly relevant
chain, oldest first, is:
- `e84a388e` - pure Core retail SetPosition transaction;
- `4c02ac42` - Runtime deferred/lost-cell residence owner;
- `270f5154` - dormant placement receipt channel;
- `237d1184` - retail SetPosition collision-report owner;
- `442cb8f9` - exact authored mover preparation;
- `22651c82` - dormant Runtime local physics publication;
- `99f867f0` - sealed dormant SetPosition evaluation;
- `5785a07b` - dormant SetPosition activation;
- `ef436678` - placement acknowledgement ownership;
- `74c9b155`, `378ca95a`, `f05ed5c3` - graphical/headless projection seams;
- `99bf1751`, `9b0f59bd` - collision-prefix quiescence and atomic replacement;
- `0fbc7a1f` - hidden-object SetPosition ownership correction;
- `3f800a4a` - authoritative route classification;
- `74103f75` - inert App materialization before Runtime placement;
- `38fd4b8d` - initial Create residence, continuation FIFO, and adoption.
## Owned mechanism in `38fd4b8d`
`RuntimeInitialCreateResidenceState` now owns, per exact entity incarnation:
- the accepted initial Create frame and exact SetPosition operation;
- a cellless logical entity while authored placement is pending;
- a monotonic immutable FIFO for fresher Position continuations;
- accepted timestamp, position, vector, rotation, placement, and wire payloads;
- completion/adoption tokens and a revision which reject stale observers;
- exact authority revalidation across generation, identity, Create, position,
placement, full-cell, deletion, reset, GUID reuse, and disposal;
- reentrant-safe cancellation at the lifetime commit boundary.
The public legacy registration path is intentionally unchanged. Production
behavior remains on the previous route until the continuation executor and
all-host cutover land together.
### Exact behavior now protected
- Initial/New Create admission is previewed without consuming timestamps;
Existing and Stale packets still use the established gates.
- No collision generation is guessed. An initial residence can exist only
after binding a real, nonzero generation.
- Fresh Parent wins over Position, matching the packet's relation priority.
- An absent or present-zero position cell remains cellless instead of being
fabricated as an outdoor placement.
- Later accepted Position packets append to one immutable ordered FIFO. They
cannot mutate the original placement operation or bypass it.
- A completed but not yet adopted transaction remains exclusive. A later
Position revises the retained batch and invalidates the old adoption token;
it cannot disappear between completion and acknowledgement.
- Placement acknowledgement uses exact identity, operation, generation,
position authority, Create integration, full-cell, and placement-commit
versions.
- Reset first detaches and clears ownership, then publishes cancellation, so a
reentrant observer cannot invalidate enumeration or resurrect an owner.
- Delete, replacement, pickup, parent, withdrawal, reset, and disposal return
cancellation receipts to the caller's safe publication boundary instead of
invoking observers before later canonical mutation.
- Malformed initial or continuation packets fail before timestamp or canonical
state consumption. A corrected packet with the same instance can recover.
The FIFO stores raw accepted Position facts rather than prematurely choosing
a final movement route. That is intentional: contact, animation state, the
server-position option, and player distance must be sampled at the same point
where retail makes the routing decision.
## Exact files in `38fd4b8d`
- `src/AcDream.Core/Physics/PhysicsTimestampGate.cs`
- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs`
- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs`
- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
- `tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs`
- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs`
## Retail order for the next slice
The next slice must preserve `SmartBox::HandleCreateObject` at `0x00454C80`:
1. visual description;
2. exactly one of Parent, Position, or Pickup relation;
3. Movement;
4. State;
5. Vector;
6. Weenie description;
7. final resident-cell validity cleanup.
Position routing must also preserve these named-retail distinctions:
- a same-incarnation Create position is not equivalent to standalone F748;
- ForcePosition performs its own timestamp/parent/placement route;
- remote near-contact interpolates, remote far-contact stops interpolation and
performs SetPosition, and remote teleport invokes the teleport hook before
SetPosition;
- local teleport performs SetPosition, then the player-teleported hook, then
constrains to the authoritative frame and clears velocity;
- local ordinary Position constrains first and interpolates only when the
server-position option and contact gate permit it.
Therefore every retained continuation must include its
`RuntimeAcceptedPositionSource`, and executor-time inputs must pin HasAnims,
UsePositionFromServer, contact, and distance before mutation. Parent/Pickup and
the same-Create Movement -> State -> Vector order must be part of the same
synchronous adoption transaction.
The principal named-retail anchors are:
- `SmartBox::HandleCreateObject` `0x00454C80`;
- `CPhysicsObj::SetPositionInternal` `0x00515330`;
- `PhysicsDesc::UnPack` `0x0051DDD0`;
- `CPhysicsObj::set_description` `0x00514F40`.
Use `docs/research/named-retail/acclient_2013_pseudo_c.txt` first and the older
Ghidra chunks only as a fallback.
## Validation and reviews
The implementation agent and reviewers reported:
- focused initial-residence and classifier tests: 79/79;
- complete Runtime tests: 819/819;
- Runtime Release build: zero warnings and errors;
- focused Core timestamp tests: 31/31;
- retail-conformance review: clean;
- architecture/adversarial review: clean;
- `git diff --check`: clean.
Primary-agent final gates after the behavior commit:
- complete Release solution build: succeeded, 0 errors;
- complete Release solution tests: 10,612 passed / 4 intentional skips;
- App: 4,027 passed / 3 skips;
- Core: 4,242 passed / 1 skip;
- Runtime: 819 passed;
- Core.Net: 762 passed;
- UI abstractions: 543 passed;
- Headless: 76 passed;
- Content: 124 passed;
- Bake: 15 passed;
- CLI: 4 passed.
The build reports 21 pre-existing test-project nullable/analyzer warnings. The
checkpoint introduces no build errors or new production warning.
Both independent reviews initially found real edge cases and the final code
includes their root-cause fixes:
- completed-but-unadopted Position packets could bypass the FIFO;
- cancellation callbacks could re-enter before the caller's canonical mutation;
- reset could enumerate live dictionaries while a callback mutated them;
- adoption did not initially validate every spatial/authority version.
Final retail-conformance and architecture/adversarial rereviews both passed.
No connected visual gate was required because the new API is dormant and no
production App or Headless route calls it yet.
## Production routes intentionally unchanged
This is the key handoff boundary. At this checkpoint:
- graphical Create still flows through
`LiveEntityHydrationController.OnCreateCore`,
`LiveEntityRuntime.RegisterLiveEntity`, and legacy `RegisterEntity`;
- graphical materialization still defaults to `LegacyImmediate` rather than
the new `AwaitRuntimePlacement` residence;
- graphical Position still performs its existing world-position, rebucket,
projectile, remote-motion, and shadow work;
- headless Create still uses `RuntimeLiveEntitySessionController.OnSpawned`,
`HeadlessSessionWorldProjection.ProjectSpawn`, and its independent initial
resolve/body construction;
- headless Position still uses its existing projection path;
- the new Runtime initial-residence API is reached by focused tests only.
Existing host adapters already observe Runtime placement receipts. Do not add
another observer architecture or a second GUID map.
## Next implementation boundary
Implement one Runtime continuation executor and exact ordered Create tail,
then route graphical and no-window registration through it without a mirror.
The executor must be synchronous or retry-idempotent around adoption revision;
failure must leave the exact FIFO head retryable. Only after both production
hosts and every Create/Position/ForcePosition/parent/pickup route use the same
owner may AP-1 and AD-1 retire.
### Required order for the next model
1. Add `RuntimeAcceptedPositionSource` to every retained continuation. A
same-incarnation Create position and standalone F748 are not interchangeable.
2. Implement one Runtime-owned synchronous continuation executor. Capture
`UsePositionFromServer`, animation/contact state, and player distance at the
retail-equivalent decision point.
3. Execute initial placement once, consume its exact host acknowledgement,
then drain the continuation FIFO in order with retail's hook ordering.
4. Serialize one Create packet as relation
(Parent/Position/Pickup), Movement, State, Vector, WeenieDesc, cleanup.
5. Keep every side effect exactly-once. If execution can yield, make adoption
revision/idempotence explicit so retry cannot replay hooks or position sends.
6. Switch graphical and headless registration together to the same Runtime
owner. Hosts may project immutable results only; they may not resolve a
second placement or create another body.
7. Route later Create, Position, ForcePosition, teleport, parent, pickup,
withdrawal, delete, remote, projectile, and dropped-item edges through the
same owner before deleting legacy paths.
8. Run focused tests, full Release build/tests, exact lifecycle/reconnect and
nine-stop connected gates, then perform the user visual matrix. Only then
retire AP-1 and AD-1.
Do not begin AP-22 or AD-10 until the production placement cutover is green.
### Subsequent independent slices
- **AP-22:** make `ShadowShapeBuilder` the only prepared Setup-shape authority;
preserve authored cylinder order, use spheres only when cylinders are absent,
allow truly shapeless Setups, and remove radius/height synthesis and sphere-
to-cylinder coercion across graphical/headless/live publication.
- **AD-10:** remove terrain-normal preprojection from remote motion. Let the
canonical transition resolver project against the actual retained contact
plane, with tests where terrain and BSP/prop normals deliberately differ.
- Run the final connected matrix, synchronize ledgers/docs, and only then close
the remaining physics-divergence campaign.
## Rollback
Revert the behavior checkpoint without disturbing the earlier placement
foundation:
```powershell
git revert 38fd4b8dc952236d4b98518c67335026c7815656
```
The documentation checkpoint containing this file is a separate commit and
can be reverted independently if only the handoff text needs correction.
## Resume checklist
1. Continue in the exact worktree and branch recorded above.
2. Confirm `git rev-parse HEAD` includes both the behavior and documentation
checkpoint commits.
3. Read this file, `docs/architecture/acdream-architecture.md`,
`docs/research/2026-07-31-canonical-set-position.md`, and
`docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md`.
4. Run `git status --short` and preserve every unrelated path listed above.
5. Re-run the focused 79-test residence/classifier gate before modifying the
transaction.
6. Begin only the continuation executor and ordered Create tail. Do not start
AP-22/AD-10 or vendor work in the same commit.

View file

@ -1,320 +0,0 @@
# Runtime initial-placement admission handoff - 2026-08-01
> **Status:** this remains the `30012361` admission-checkpoint history. The
> continuation executor this file scoped as "the next implementation
> boundary" is complete at `5db3de3c`; the current boundary (production
> cutover) is recorded in
> [`2026-08-02-runtime-continuation-executor-handoff.md`](2026-08-02-runtime-continuation-executor-handoff.md).
## Purpose and exact stopping point
Behavior commit `30012361e12222e8271b1531574257ba910c77cb`
completes the bounded Runtime admission checkpoint requested by the user.
While an entity's first authored placement is waiting, every later accepted
same-incarnation update is preserved in exact arrival order without changing
or displaying the entity early.
In plain terms, Runtime now has a sealed mailbox behind the pending initial
placement. Network sequence checks still decide which messages are fresh, but
accepted messages wait in that mailbox. The visible/canonical entity remains
at its original frozen Create state until a later executor is authorized to
apply the mailbox.
This checkpoint deliberately does **not** implement that executor, switch the
graphical or headless production routes, begin AP-22 authored shape work, or
begin AD-10 remote slope projection. AP-1 and AD-1 therefore remain open.
This file supersedes the admission-status portions of
`2026-08-01-runtime-initial-create-residence-handoff.md`; that earlier file
remains the foundation history for commit `38fd4b8d`.
## Exact workspace and Git state
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
- Branch: `codex/port-claude-agents`
- Behavior checkpoint: `30012361e12222e8271b1531574257ba910c77cb`
- Residence foundation: `38fd4b8dc952236d4b98518c67335026c7815656`
- Documentation checkpoint: the commit containing this file
- No push or merge is part of this checkpoint.
The worktree intentionally contains unrelated user changes or pre-existing
stat/line-ending noise. Do not stage, restore, normalize, or rewrite these
paths when continuing:
- `AGENTS.md` (real unrelated content change);
- `src/AcDream.App/Input/PlayerModeController.cs`;
- `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs`;
- `src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs`;
- `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`;
- `tests/AcDream.Core.Tests/Physics/CellTransitTests.cs`;
- `tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs`;
- `tools/A8CellAudit/A8CellAudit.csproj`.
Always stage exact paths. Never use `git add -A` in this worktree.
## What `30012361` owns
### One exact pending owner
`RuntimeInitialCreateResidenceState` owns one transaction per exact
`RuntimeEntityKey`, not per server GUID alone. It retains:
- the deep-frozen initial Create packet and placement operation;
- the exact residence token, generation, placement authority, and revision;
- a monotonic sequence for accepted continuations;
- one immutable, mixed-kind FIFO in original arrival order;
- completion/adoption and teardown receipts.
The accepted continuation kinds are:
1. same-incarnation Create;
2. ObjDesc;
3. Parent;
4. Pickup;
5. Position;
6. Movement;
7. State;
8. Vector.
There is no coalescing, sorting by message type, or replacement of an earlier
accepted FIFO item by a later one.
### Frozen public state
While the initial residence is pending:
- retail timestamp gates advance for accepted updates;
- `RuntimeEntityRecord.Snapshot` remains unchanged;
- the public accepted-snapshot view remains unchanged;
- no entity/object event is published;
- no projection acknowledgement callback runs;
- parent commitment, world placement, rendering, radar, picking, physics,
audio, and other presentation remain unchanged;
- every accepted payload is retained as an immutable typed action.
This is intentional gate-only acceptance. It is not an alternative canonical
snapshot and must not grow into one.
### Immutable payload boundary
`RuntimeInitialCreateAdmissionFreezer` copies every parser-owned mutable
collection that can outlive packet dispatch:
- EntitySpawn animation-part, texture, and sub-palette arrays;
- ObjDesc model arrays;
- motion command lists;
- Physics Movement raw bytes and motion commands;
- Physics child attachments.
Same-incarnation Create is retained as one atomic envelope. Its actions retain
retail's packet-tail order:
1. AP-119 pre-tail description adaptation;
2. ObjDesc;
3. exactly one of Parent, Position, or Pickup;
4. Movement;
5. State;
6. Vector;
7. Weenie description;
8. resident-cell cleanup.
### Position facts remain raw
A deferred Position retains the typed packet plus the timestamp disposition
and accepted gate facts. It does not prematurely choose interpolation,
teleport hooks, or final movement behavior. The new explicit
`RuntimePositionConstrainPhase` distinguishes retail's local ordinary
constrain-before route from remote/teleport constrain-after routes, but the
future executor must still sample the required live inputs at the retail
decision point.
No selected UI target, presentation state, or host-specific route is stored in
the admission owner.
## Missing-parent behavior
Named retail resolves a nonzero parent before child object lookup and child
timestamp admission. Runtime now follows that order:
- a child Create whose parent is not addressable is stored as the complete raw
frozen Create packet;
- no child entity record, accepted snapshot, timestamp gate, local ID, event,
or residence lease exists yet;
- the queue is keyed by parent GUID but each entry also has a monotonic
`AdmissionId` which is never reset, preventing reset/reconnect ABA reuse;
- a later parent Create may consume only the exact admission token it peeked;
- deleting or replacing a still-missing parent does not discard its queued
child Create, matching retail's GUID-keyed placeholder behavior;
- an exact child Delete removes an equal/older deferred child generation even
if no child timestamp gate exists;
- generation cleanup preserves an equal or newer deferred child Create and
discards only older ownership.
Raw missing-parent replay and actual child creation belong to the future
continuation executor/cutover. They are not performed by this checkpoint.
## Malformed and saturation behavior
All structural and capacity checks run before consuming a timestamp gate.
- Non-finite Vector and Position payloads are rejected without sequence
consumption.
- A full/saturated continuation owner fails before gate acceptance; there is
no fallback to ordinary immediate mutation.
- Flattened EntitySpawn projections must exactly agree with the embedded
PhysicsDesc for identity, Position, relevant timestamps, parent, and
placement.
- When PhysicsDesc is absent, every flattened PhysicsDesc projection must also
be absent or zero: Position, Setup, Motion, PhysicsState, scale, friction,
elasticity, timestamps, parent, and placement.
- Instance sequence zero remains legal and is covered on the active pending
FIFO path.
The last rule prevents synthetic or corrupt packets from creating two
contradictory placement authorities even though the production parser normally
constructs those projections from one source.
## Lifetime and failure guarantees
- Delete cancels the matching residence and its FIFO before the exact entity
can be reused.
- New incarnation/GUID reuse cannot observe or adopt an older incarnation's
FIFO.
- Session reset/reconnect clears active residence, completed-unadopted batches,
deferred raw creates, accepted timestamp ownership, and operation state.
- Reentrant teardown callbacks cannot resurrect the detached owner.
- Completion/adoption revisions cannot wrap into a valid stale token.
- Parent raw-admission IDs cannot wrap or reset into an ABA match.
- Every ownership ledger converges to zero on reset/disposal.
## Named-retail oracle
The behavior and reviews used these named-retail anchors:
- `SmartBox::HandleCreateObject` `0x00454C80` - Create packet ordering and
missing-parent precondition;
- `SmartBox::ProcessObjectNetBlobs` `0x00454B20` - queued packet replay order;
- `SmartBox::HandleReceivedPosition` `0x00453FD0` - standalone Position route;
- `SmartBox::HandleDeleteObject` `0x00451EA0` - GUID-keyed delete behavior;
- `ACCObjectMaint::CreateObject` `0x00558870` - logical object creation;
- `CPhysicsObj::set_description` `0x00514F40` - PhysicsDesc application order;
- `CPhysicsObj::SetPositionInternal` `0x00515330` - canonical placement.
Research must continue from
`docs/research/named-retail/acclient_2013_pseudo_c.txt`; use the older Ghidra
chunks only as a fallback.
## Files in the behavior checkpoint
- `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs`
- `src/AcDream.Runtime/Entities/ParentAttachmentState.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateAdmissionFreezer.cs`
- `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs`
- `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs`
- `tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs`
- `tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs`
## Automated evidence
Final primary-agent gates on the exact behavior diff:
- focused initial-residence/classifier tests: **89 passed, 0 failed**;
- complete Runtime tests: **829 passed, 0 failed**;
- complete Release build: **0 warnings, 0 errors**;
- complete Release solution: **10,622 passed, 4 intentional skips**;
- `git diff --check`: clean.
Per-project final solution totals:
- App: 4,027 passed / 3 skipped;
- Bake: 15 passed;
- CLI: 4 passed;
- Content: 124 passed;
- Core.Net: 762 passed;
- Core: 4,242 passed / 1 skipped;
- Headless: 76 passed;
- Runtime: 829 passed;
- UI abstractions: 543 passed.
Independent final results:
- retail-conformance reviewer: **PASS**;
- architecture/adversarial reviewer: **PASS**.
The reviews explicitly checked exact retail order, gate-only frozen state,
missing-parent placeholder lifetime, zero instance, delete/reset/GUID reuse,
deep freezing, malformed duplicated projections, capacity preflight,
reentrancy, and the absence of executor/cutover work.
No connected visual gate is required for this checkpoint because production
graphical and headless routes remain unchanged and the new owner is exercised
only through deterministic Runtime tests.
## Deliberately unchanged production routes
At this checkpoint:
- graphical Create/Position still use the existing App route;
- headless Create/Position still use the existing no-window projection route;
- no host drains `RuntimeInitialCreateResidenceState.Continuations`;
- no raw missing-parent child Create is replayed;
- no new gameplay/presentation callback is emitted;
- no AP-1 or AD-1 divergence row is retired;
- AP-22 and AD-10 are untouched.
Do not mistake the stored FIFO for completed game behavior. The clean next
boundary is the executor that applies it.
## Next implementation boundary
Implement only the Runtime continuation executor and retail Create tail.
The executor must:
1. consume the exact initial placement acknowledgement once;
2. capture executor-time inputs at the retail decision point;
3. apply the initial Create tail in retail order;
4. drain mixed continuations strictly by retained sequence;
5. preserve same-Create atomicity;
6. keep the FIFO head retryable if an external host receipt is temporarily
unavailable;
7. make every hook, timestamp, placement, and event side effect exactly once;
8. consume a raw missing-parent Create only through its exact `AdmissionId`;
9. abandon safely on delete, reset, replacement, or generation mismatch;
10. produce host-independent immutable results rather than calling App or
headless presentation directly.
Do not combine the executor with graphical/headless cutover. After the
executor is independently green, the following checkpoint may switch every
Create, Position, ForcePosition, Parent, Pickup, withdrawal, remote,
projectile, dropped-item, and teardown route across both hosts together.
Only after executor plus all-host cutover and connected gates pass may AP-1
and AD-1 retire. AP-22 and AD-10 remain later independent slices.
## Rollback
Revert this behavior checkpoint without disturbing the prior residence
foundation:
```powershell
git revert 30012361e12222e8271b1531574257ba910c77cb
```
The documentation checkpoint containing this file is separate and may be
reverted independently if only the handoff text needs correction.
## Resume checklist
1. Open the exact worktree and branch above.
2. Confirm `git log -3 --oneline` contains behavior `30012361` and the
documentation commit containing this file.
3. Preserve every unrelated dirty path listed above.
4. Read this file, `docs/architecture/acdream-architecture.md`,
`docs/research/2026-08-01-runtime-initial-create-residence-handoff.md`, and
`docs/research/2026-07-31-canonical-set-position.md`.
5. Re-run the focused 89-test gate before changing admission/execution code.
6. Begin only the continuation executor. Do not begin production cutover,
AP-22, AD-10, or vendor work in the same checkpoint.

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