Compare commits

..

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

1848 changed files with 27113 additions and 671634 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,30 +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="/tools/">
<Project Path="tools/A8CellAudit/A8CellAudit.csproj" />
<Project Path="tools/dump-keymap/dump-keymap.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/RetailTimeProbe/RetailTimeProbe.csproj" />
<Project Path="tools/SetupInspect/SetupInspect.csproj" />
<Project Path="tools/ShaderCompiler/ShaderCompiler.csproj" />
<Project Path="tools/SkyObjectInspect/SkyObjectInspect.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" />
@ -41,14 +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.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder>

281
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)
@ -1569,26 +1377,8 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
animation debugging.
- `ACDREAM_STREAM_RADIUS=N`**legacy** streaming-radius override
(`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the
shipped radii come from the quality preset
(`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring
inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only
ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is
silently discarded by any later Settings `ApplyQuality`
(`RuntimeSettingsTargets.ApplyQuality``ReconfigureRadii`). **Leave it
unset for any measurement or gate run** — with it set you are measuring a
different window than production. Per-axis overrides
`ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`)
are the modern spelling.
- `ACDREAM_PROBE_REVEAL_RADIUS=N`#280 A/B measurement probe
(`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal
gate to landblock radius N instead of the derived streaming window, so the
same binary can run a route once with the pre-#280 behaviour (`=1`) and once
without. Not a user setting; not surfaced in Settings; not persisted.
Values below 1 are rejected by the parser: an outdoor acknowledgement with
`RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape`
invariant, so `=0` would hang the route it is meant to measure.
- `ACDREAM_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
@ -1622,29 +1412,6 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
collision geometry where the visual geometry is?** (#337, TEMPORARY).
`[support]`: one line per resolve **for every body, not just the player**
(a corpse falling through geometry is the cheapest control there is on
"movement code vs geometry data"). It samples the outdoor terrain
independently at the body's own out-XY and prints the contact plane's own
height at that same XY, so `support=terrain` / `object` / `none` is a
measurement rather than an inference; `cpSrc=` names the site that wrote
the plane so provenance cross-checks the classification. Edge-eager,
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
cloud against its visual mesh AABB in the same frame, with a verdict
(`coincident` REFUTES "collision isn't where the visual is";
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
those cases — it carries no plane normal, no plane height, no terrain
sample and no provenance.
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
beside the same objects' visual mesh boxes (magenta) and the terrain
surface (yellow). Settles "visual versus collision" by eye instead of by
log. `ACDREAM_WIRE_RADIUS=<metres>` sets the window (default 30).
TEMPORARY, with the #337 probe family.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND

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,10 +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.
- [`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,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)`.
@ -2115,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** ✓ |
@ -2131,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,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,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 44 projects,
including all 13 maintained .NET tools; data-dependent tools are built but are
not executed as tests.
Build and dependency policy is repository-owned:
- `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

@ -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

@ -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.

View file

@ -1,287 +0,0 @@
# Runtime local-player physics publication - 2026-08-01
## Scope
This is placement Slice 4B2 checkpoints 4-6. It adds the dormant,
presentation-independent transaction which prepares and assigns ownership of
one local-player `PhysicsBody` and `PlayerMovementController`, retains an exact
post-ownership evaluation lease, and commits the canonical Runtime SetPosition
activation in retail order. No App or Headless production route invokes this
transaction yet, so graphical and no-window game behavior is unchanged and
AP-1/AD-1 remain open until their hosts cut over.
Checkpoint 6 consumes the prepared placement operation only after the exact
body/controller/identity/collision envelope is current. It publishes FullCell,
world residence, host, shadow, workset, object-clock, and ordered Place state
from that same Runtime-owned dormant body. There is no second body, mirrored
gameplay owner, rollback mutation, or presentation callback inside the
canonical tail.
## Ownership contract
`RuntimeLocalPlayerPhysicsPublicationState` is the sole owner of unpublished
local-player body/controller candidates. Each candidate is bound to a token
containing:
- The exact `RuntimeEntityKey` and authored SetPosition placement token.
- A monotonic publication ID.
- The nonzero canonical local-player server GUID and exact identity revision.
- The record's physics-body and object-clock ownership epochs.
- The movement state's controller ownership epoch.
- The entity directory's session-lifetime authority.
Preparation constructs a private controller, body, and object clock. It applies
the exact authored cell frame, orientation, Setup sphere list, scale, step
heights, and accepted final physics state without mutating the canonical entity,
shared object clock, engine/worksets, shadow registry, FullCell, host state, or
presentation. The candidate remains explicitly out of world and inactive. No
method exposes its controller, body, clock, or another mutable reference while
it is owned by the publication transaction.
This checkpoint accepts only a pristine initial graph: no canonical body,
movement controller, physics host, remote motion, projectile, acquisition or
binding operation, or remote-placement contract may exist. It cannot replace or
upgrade a live graph. The local-player identity must be live, nonzero, and name
the same server GUID as the exact entity incarnation.
Unpublished candidates and ownership-committed dormant controllers reject live
movement operations: update, public SetPosition, blip, outbound-position
capture, movement/position send tracking, and shared-engine position commit.
Only the checkpoint-6 activation transaction may promote `RuntimeOwnedDormant`
to `RuntimePublished`; preparation and evaluation never invoke that transition.
Once a
Runtime-owned dormant or published controller is replaced, reset, or disposed,
its terminal retirement state rejects the same operations plus
body/configuration mutation and manager acquisition. Publicly constructed
legacy controllers keep their existing standalone behavior.
## Failure-atomic commit
Commit revalidates every authority after preparation:
- The entity record is the current incarnation and is not accepted for delete.
- The local-player identity still has the token's exact GUID and revision and
has not been disposed.
- The exact authored SetPosition operation and sealed command remain current.
- Session, body, object-clock, and controller ownership epochs still match.
- The body/controller/host/remote/projectile graph remains completely pristine,
with no acquisition, binding, or remote-placement operation in progress.
Only after validation completes does the callback-free update-thread tail:
1. Rebind the candidate controller from its private clock to the record's exact
canonical object clock and mark it Runtime-owned but dormant.
2. Store the candidate's exact body on the canonical record, advancing the
physics ownership epoch once.
3. Store the same controller in `RuntimeLocalPlayerMovementState`, advancing the
controller ownership epoch once.
The dormant controller rejects every live/configuration operation after these
stores; ownership commit alone cannot tick physics, mutate the canonical clock,
or publish an outbound frame. These stores allocate no new gameplay owner,
invoke no host or presentation callback, and cannot replay an older
incarnation. Replacing SetPosition,
changing any accepted physics authority, binding remote/projectile state,
replacing body/clock/controller ownership, delete plus GUID reuse, reset, or
disposal causes the token to reject. An identity switch away and back also
rejects because its revision changed. A rejected or superseded candidate is
discarded and cannot perform a later live operation. Reset and disposal converge
the publication ledger to zero candidates.
Repeated stores of the same body/controller do not advance their epochs; real
bind, replacement, and unbind edges do. This makes ABA-shaped reference changes
observable even if a later value happens to equal an earlier reference.
## Dormant SetPosition evaluation lease
Ownership commit now returns one private activation token captured only after
the canonical body and controller stores. It binds the exact entity key,
authored placement token and sealed command, local identity GUID/revision,
session lifetime, and the post-store physics-body, object-clock, and controller
ownership epochs. The owner retains the same record, body, controller, and
command behind that token; no caller can substitute an equivalent-looking
body or rebuild the mover.
`EvaluateActivation` revalidates that complete lease and calls Core
`PhysicsEngine.SetPosition` synchronously with an immutable request. Core's
transaction is pure: it returns committed, deferred-cell, or rejected
placement data without writing the canonical body, FullCell, clock, spatial
worksets, shadows, collision-report owners, host, operation stage, or Place
projection. A missing cell therefore leaves the exact body dormant and the
authored operation retryable. During evaluation, a valid result likewise
remains only an immutable receipt; checkpoint 6's separate commit API consumes
that receipt only after revalidating the complete activation envelope.
Each evaluation carries an append-only, stable-order union of every cell read
by the complete Core transaction: the AdjustPosition seed and adjusted cell,
visible-child probes (including rejected lateral siblings), rejected
portal/building containment probes, transition/compass retries, and every
normal or scatter attempt. Rejected probes enter only the authority union and
never the final successful shadow/CrossCell footprint. Scatter keeps that union
in retained scratch and materializes its
immutable receipt exactly once after the final attempt, avoiding quadratic
copy/allocation growth at the 64-attempt retail ceiling. The final
`CrossCellIds` remains the successful placement's authored
shadow footprint; failed scatter probes cannot leak into that commit payload.
Runtime seals every distinct queried landblock against the exact collision
generation, the global collision-world authority, and the dynamic-shadow
mutation revision. An active replacement admission rejects evaluation even
before it commits, while begin/cancel, a re-entrant generation commit, or any
owner insert/remove/move/state/suspend/reflood mutation invalidates an older
receipt.
Entry restrictions also consult the live `ClientObjectTable` for the resolved
house object, owner and complete restriction record, plus the mover's monarch.
The receipt therefore seals the exact object-table reference, the engine's
monotonic binding epoch, and the table's synchronous mutation revision. Object
creation/removal, owner-property, guest-list, or mover-monarch updates invalidate
the receipt; a null/fresh replacement and an equal-revision A-B-A binding cycle
cannot resurrect it. Retained `ClientObject` owner, monarch, and restriction
setters synchronously advance every exact owning table even when callers mutate
the object directly rather than re-submit it through `AddOrUpdate`. Replacement,
removal, and clear detach that observer exactly, and every
`HouseRestrictionRecord` freezes a defensive snapshot of its input guest map so
no caller-owned dictionary or mutable downcast can alter entry authority behind
the revision.
`IsEvaluationCurrent` accepts only the newest receipt for the exact activation
lease and rejects it after a position/vector/state/object-description/Create
authority change, identity revision, body/controller replacement, session or
incarnation change, or any sealed collision/shadow authority change.
Re-evaluation supersedes the older receipt without mutating world state.
Re-entrant reset or delete-plus-GUID-reuse during Core evaluation immediately
retires the invalid lease instead of leaving an orphaned dormant graph. An
existing activation lease also blocks candidate preparation even if an
external owner has already cleared the body/controller references; explicit
discard is required before a new candidate can be prepared. Reset and disposal
retire the lease, body, and dormant controller and include the pending
activation in the ownership convergence ledger.
## Canonical activation and retail ordering
The implementation follows the named-retail chain rather than treating
SetPosition as a single opaque callback:
- `CPhysicsObj::SetPosition` at `0x005160C0` owns the outer placement call.
- The internal wrapper at `0x00515BD0` evaluates residence and collision.
- `CPhysicsObj::SetPositionInternal(CTransition*)` at `0x00515330` commits the
accepted frame/contact prefix and later shadow/cell state.
- `CPhysicsObj::enter_world` at `0x00516170` is the final live edge.
- `CPhysicsObj::leave_world` at `0x005155A0` is the canonical retirement edge.
Runtime splits that chain into a prepared, callback-free transaction and an
ordered notification suffix:
1. Install the accepted frame and contact prefix on the still-dormant body and
perform the first acceleration calculation.
2. Open one narrow dormant ground phase and invoke `HitGround` or
`LeaveGround`. Movement reapplication may call retail `set_velocity`, but
the phase closes with `Active=false`; the body is still out of world, has no
host/spatial membership, and its object clock is inactive.
3. Synchronize accepted State and Vector authorities, run the post-ground
acceleration/sliding phase, and dispatch the already-installed collision
batch.
4. Revalidate the complete ownership/collision envelope. Accepted State and
Vector updates are synchronized; Position, ObjDesc, Create, Setup,
incarnation, identity, collision-generation, body, controller, host, or
session displacement aborts the old transaction.
5. Apply velocity-current physical response and stationary bits, prepare the
final shadow mutation and Place receipt, then perform the callback-free
FullCell/body/host/controller/spatial/object-clock tail.
6. Dispatch exact shadow notifications and the ordered Place projection only
after the complete live graph is visible.
Collision and shadow mutations use explicit prepare/apply/dispatch receipts.
Receipt dispatch is exact-once and owner-local, so reverse-order receipts for
different owners remain valid while a superseding mutation of the same owner
stops the stale suffix. Collision owner states carry the exact SetPosition
batch ID. Reentrant Position or newer-batch replacement suppresses remaining
reciprocal/environment callbacks, and abort cleanup force-ends/removes only the
still-exact old batch, including reverse rows and the environment latch. The
combined Runtime physics ownership ledger includes pending collision and
shadow SetPosition receipts; teardown cannot report convergence while either
receipt remains.
Candidate construction applies the accepted `PhysicsDesc` values in retail
`CPhysicsObj::set_description` order before sealing ownership: final state,
friction, clamped elasticity, `set_velocity` (including the 50-unit clamp),
and angular velocity. Network acceleration remains parse-only because retail
recalculates it from the final physics state. This initial vector bootstrap is
required even when the SetPosition receipt's source Vector authority is still
current; the later refresh intentionally skips in that case. Collision
callbacks may advance State/Vector authority without invalidating the
immutable geometry/identity envelope, and a changed Vector authority refreshes
the dormant body through the same `set_velocity` path before physical response.
A deferred-cell commit atomically suspends an authored shadow registration and
consumes its notification receipt. Explicit publication discard cancels the
exact SetPosition lease and body/controller ownership, while the suspended
registration remains owned by the live entity/shadow registry and is reusable
by a later activation. A deterministic discard -> generation-ready -> new
activation gate proves the same registration restores without stale rows or a
pending receipt. Entity/lifetime teardown remains the terminal owner of that
suspended registration.
## Gates
- Candidate privacy and live-operation rejection.
- Pristine-only admission for body, controller, host, remote/projectile,
acquisition/binding, and remote-placement ownership.
- Exact local-player identity, identity-switch, and disposed-identity rejection.
- Exact same-body ownership in entity record and dormant movement controller.
- Initial PhysicsDesc velocity, angular velocity, friction, and elasticity
bootstrap, including activation with the retail 50-unit velocity clamp.
- Dormant rejection after ownership commit plus the controller-level
`dormant -> activated -> live` lifecycle contract exercised by checkpoint 6.
- No mutation of SetPosition, FullCell, spatial roots, host projections,
shadows, worksets, world residence, or presentation during preparation or
evaluation; the separately gated activation commit owns those mutations.
- Replacement by position, vector, final physics state, object description,
CreateObject, remote/projectile/body/clock/controller ownership, and explicit
placement cancellation.
- Delete plus same-GUID reincarnation.
- Candidate replacement, reset, disposal, and ownership convergence.
- Publication/activation sequence exhaustion is preflighted before candidate
allocation or replacement, leaving no private or canonical owner behind.
- Shadow-registry reset invalidates even a prepared, unapplied shapeless
transaction which owns no logical rows or pending dispatch receipt.
- Pure committed/deferred/rejected SetPosition evaluation with bit-exact
body-state snapshots and no canonical, collision-report, projection,
clock, FullCell, host, shadow, workset, or operation-stage mutation.
- Complete stable-order queried-cell capture across AdjustPosition,
visible-child lookup, normal/scatter retries, map-edge/deferred, rejected,
committed, and defensive NoCell outcomes. Scatter deliberately retains
retail's RNG consumption; only its authority footprint and commit payload
are deterministic for a fixed draw sequence.
- Newest-receipt selection, active-admission rejection, collision-generation
replacement, re-entrant begin/cancel and commit invalidation, plus dynamic
shadow insert/move/state/suspend/remove invalidation.
- Exact object-table reference/revision/binding authority, including
post-evaluation and re-entrant house-object, owner, guest-list, and mover-
monarch mutations plus null/fresh/equal-revision ABA replacement. Direct
retained-object setters, replacement/removal/clear observer lifetime, shared
multi-table ownership, and frozen guest-map input are covered explicitly.
- Re-entrant reset and delete/GUID-reuse convergence plus activation-lease
overwrite prevention after an external body/controller clear.
- Post-ownership position, vector, object-description, Create, identity,
body, and controller authority replacement.
- Terminal stale-controller rejection after replacement, reset, and disposal.
- Body/controller epochs advance only on actual ownership changes.
The checkpoint-6 focused publication/collision suite passes 129/129, the
focused Core shadow transaction suite passes 16/16, and the complete Runtime
project passes 695/695 under invariant globalization. The Runtime Release build
passes with zero warnings and zero errors. Broader Core/App/solution and
connected gates remain for the parent integration checkpoint. Under the
machine's Swedish current culture, the three previously known formatting
assertions remain unrelated (`0,5` versus `0.5` and localized sky text), so the
canonical Runtime gate runs under invariant globalization.
## Next checkpoint
Cut the graphical and no-window local-player hosts over to this Runtime-owned
activation transaction, then delete their duplicate SetPosition
activation/publication paths. The cutover must preserve the same exact body,
controller, shadow payload, deferred-cell lease, collision receipt ordering,
and graceful teardown proven here; no host may reconstruct or replay the
canonical transaction.

View file

@ -1,106 +0,0 @@
# Runtime SetPosition authored mover preparation - 2026-08-01
## Scope
This is placement Slice 4B2 checkpoint 3. It adds the dormant,
presentation-independent preparation contract used to turn an accepted Runtime
position into retail's exact `CPhysicsObj::SetPosition` mover input. No App or
Headless production route consumes the contract yet, so game behavior is
unchanged and AP-1/AD-1 remain open.
## Retail oracle
The implementation was checked against the named September 2013 client:
- `PhysicsDesc::PhysicsDesc` `0x0051D4D0`
- `PhysicsDesc::UnPack` `0x0051DDD0`
- `CPhysicsObj::set_description` `0x00514F40`
- `CPhysicsObj::SetPosition` `0x005160C0`
- `SPHEREPATH::init_sphere` `0x0050C670`
- `CPartArray::GetNumSphere` `0x00518060`
- `CPartArray::GetSphere` `0x00518070`
- `CPartArray::GetStepUpHeight` `0x005180D0`
- `CPartArray::GetStepDownHeight` `0x005180F0`
- `CTransition::init_object` `0x00509E40`
- `OBJECTINFO::init` `0x0050CF30`
SetPosition calls `CTransition::init_object(..., state = 0)` directly. It does
not use the ordinary-movement `CPhysicsObj::get_object_info` path. Consequently
the SetPosition state carries the player/PK/PKLite/impenetrable classifications
(plus acdream's pointer-free entry-restriction carrier), but it does not add
Contact, OnWalkable, PathClipped, FreeRotate, or EdgeSlide. Ethereal and
`step_down = !Missile` are separate `OBJECTINFO` fields derived from the current
physics state.
## Exact preparation contract
- Runtime captures the complete accepted server frame under the exact entity,
session, position, vector/velocity, wire-state, final-physics-state mutation,
object-description, and create-integration authorities. A host cannot
substitute a second position.
- Collision-world X/Y uses the target landblock's active live-centered offsets;
full cell ID, cell-local XYZ, and the complete quaternion remain unchanged.
- Setup resolution is bound to the canonical Setup DID. A known but unavailable
Setup remains retryable. Resolved-absent is valid only when the canonical
object has no Setup. An authored empty Setup remains distinct and still
contributes its scaled StepUp/StepDown heights.
- The complete ordered Setup sphere list is retained. Core later applies
retail's `min(count, 2)` traversal cap. The successfully resolved no-PartArray
or zero-sphere arm reaches Core with an empty list, where SetPosition supplies
the retail dummy sphere `(0,0,0.1)`, radius `0.1`, scale `1.0`.
- Scale precedence is `PhysicsDesc.Scale ?? EntitySpawn.ObjScale ?? 1.0`.
Present zero and finite negative values are preserved. Scale is not consumed
by a resolved-absent dummy mover.
- Every authored command is sealed to the exact preparation operation. Manual,
stale, replaced, or merely value-equivalent commands cannot bypass the seal.
- A wire-state, final-state mutation (including NoDraw and missile-stop),
vector/velocity, description, or create change during a deferred cell wait
returns the resident to `AwaitingPreparation`; the stale mover is never
replayed when the collision generation wakes.
- Preparation mutates no body, clock, FullCell, spatial/shadow registration,
bucket, camera, world entity, or presentation resource.
Legacy direct SetPosition remains a distinct token mode so the dormant slice
does not change existing call sites or their warmed allocation ceiling. If a
legacy operation becomes deferred and later needs new authored data, the
presence of Runtime's preparation authority makes the exact seal mandatory.
## Ownership and validation
`RuntimeSetPositionState` owns one exact-key preparation-authority entry only
for operations which require authored preparation. The entry dies with the
operation on replacement, acknowledgement, cancellation, delete, session
reset, or disposal and participates in terminal convergence accounting.
Preparation-only validation checks the exact cell frame, live-centered world
position, values consumed by the first two retail spheres, Setup-derived step
heights, line/scatter inputs, and bounded scatter attempts. The legacy direct
validator retains its prior behavior, including retail's dummy-sphere and
first-two-sphere semantics.
## Gates and review
- Focused authored-mover plus SetPosition residence tests: 80/80.
- Runtime Release build: zero warnings and zero errors.
- Complete Runtime project under invariant culture: 595/595.
- Complete Release solution with installed DAT/pak fixtures: 10,342 passed /
4 intentional skips.
- Retail-conformance review: canonical frame, DID binding, scale/step/sphere
behavior, exact SetPosition flags, and deferred wake checked against the
named addresses above.
- Architecture/adversarial review: command sealing, legacy promotion,
replacement, deferred wake, direct compatibility, allocation, reset, GUID
reuse, and ownership convergence checked.
The three ordinary current-culture Runtime failures are pre-existing Swedish-
locale formatting assumptions (`0,5` versus `0.5` and localized sky text); the
same complete project passes under invariant culture.
## Next checkpoint
Implement the dormant atomic local-player physics publication transaction:
prepare a private controller/body/clock without canonical mutation, evaluate
SetPosition against that candidate, then publish the exact same body relation
to `RuntimeEntityRecord` and `RuntimeLocalPlayerMovementState` in one callback-
free Runtime commit. App and Headless production activation remains a later
checkpoint.

View file

@ -1,104 +0,0 @@
# C3c production placement cutover — closeout (2026-08-02)
Behavior commit: `529e0e9d` (68 files, +5,979/833, register rows AD-61 +
AD-42 refresh in-commit). Plan:
[`2026-08-02-placement-cutover.md`](../plans/2026-08-02-placement-cutover.md).
Session evidence trail: the campaign scratchpad's `implementer-progress.md`
sections `Continuation 1-4`, `C3c-F1`..`C3c-F5`, `C3c-R1` (not committed;
summarized here).
## What shipped
Both production hosts (graphical + headless) register every initial
wire Create through the C0-C3b residence/executor/conductor machinery.
One shared `RuntimeFirstEntryDriveController` pumps the local-player and
remote conductors from the placement-receipt flow (per-frame graphical,
per-tick headless). `MaterializeProjection`/`RebucketLiveEntity` are
presentation-only strictly while the initial-create residence is ACTIVE
(exact-token check; `ExecutorCompleted` is the presentation-binding
receipt); post-residence entities take the full legacy path including
retail's `prepare_to_enter_world` (0x00511FA0) clock rebase.
`RuntimeLocalPlayerMovementState.Controller`'s setter is sealed; every
controller mutation flows through the publication lifecycle. Content-less
headless sessions (validated-legal config) keep the pre-flip direct
registration until C4/C5 revisit.
## The five fix slices (each connected-gated inside the cutover)
- **F1** — live movement-stat + server-physics application moved behind
Runtime ownership (`RuntimeMovementStatsApplication`,
`ApplyServerPhysicsState`); the post-logout ingest crash on the
retired controller is eliminated; `RuntimeMovementSkillProjection`
deleted.
- **F2** — the login activation wedge (world never revealed): the
collision-admission prefix gate factored out of the seal (reentrant
commit could yield terminal `RejectedAuthority`), the rearm's
generation identity corrected (parked G vs post-retirement G+1), and
`PlayerModeAutoEntry` now requires the Runtime-published controller
(`IsPlayerControllerReady` was a constant `true` — one early attempt
permanently sealed the reveal).
- **F3** — landblock-prefix `0`-sentinel replaced by explicit absent-id
representation; map-corner landblocks (grid row/col 0, e.g.
`0x0000FFFF`) are legal through admission, park/rearm/retire,
quiescence, and outdoor shadow seeds.
- **F4** — diagnosis only: the nine-stop soak's convergence failure
(pendingPublications=1, farBacklog nonzero, landblock/mesh dimensions)
is **pre-existing `6b28ff99`** (2026-07-31, "make collision activation
starvation-free"): every far publication clones the complete collision
world (median ~19.7k leaves / 3.64 ms), so the queue drains ~10
landblocks/s and never catches its window. Fix requires an O(changed)
clone (structural sharing or per-landblock atomic unit) — a semantics
change to that slice's asserted one-leaf-per-step invariant; scheduled
as its own slice BEFORE C5 (whose gate matrix includes the soak).
- **F5** — local-player first-entry ground contact: retail seeds contact
from the first gravity frame's transition touch (`enter_world`
0x00516170 carries no seed; local player and remotes share the
mechanism via `HandleCreateObject` 0x00454C80). The shared
`SpawnPlacementSettler` (moved App→Core) runs at `FinalizeActivation`
exactly once; genuinely airborne spawns stay airborne; the outbound
contact bit chain is asserted end-to-end. The legacy path's
unconditional `Contact|OnWalkable|Active` force-seed (non-retail, no
plane) still runs during candidate preparation and is OVERWRITTEN by
the faithful settle (register AD-61). Fixes the user-observed
standing-cast "You can't do that while in the air!" rejections.
## Review round R1 (dual Opus: initial FAIL 2+2 MAJOR → delta PASS both)
Retail MAJORs: the login constraint leash (deleted with the legacy
resolve path; re-armed at the committed placement in
`FinalizeActivation``HandleReceivedPosition` 0x00453FD0 arms on every
accepted position) and the post-residence rebucket scope (fixed to
exact-token active-residence). Adversarial MAJORs: content-less headless
(no drive → legacy registration) and the register rows. Nine minors
fixed (owner conversion API with active-residence throw, wire-landblock
guards, drive-pending ledger in `IsConverged`, route attach/detach
latch, celless conversion for far headless remotes, doc-comment truth,
per-incarnation cylinder cache, executor-drain drift model documented +
source-pinned); two tracked (#276, #277 in ISSUES).
## Final gates
Runtime 1,003; App 4,039/3 skips; Headless 79; complete solution
**10,816 / 0 failed / 4 skips** (Release, `-m:1`). Connected
lifecycle/reconnect gate **PASS** (`connected-world-gate-20260802-175401`;
graceful exits, world-visible, zero airborne-rejection strings; run
`-174811` failed on user-interference fingerprint —
`activeTeleportCount=1` at the stable checkpoint — and is attributed,
not counted). The soak stays red for the pre-existing F4 attribution.
## Process lessons (carried to memory)
1. **Report artifacts over marker logs** — three wrong classifications
this campaign came from reading route/marker logs instead of
`report.json` (the soak "clean route" was Passed=false with 37
convergence failures).
2. **Log lifetime before absence claims** — a 26-second, 67-line log's
silence about a defect proves nothing (the 122749 misread inverted a
root-cause classification twice).
3. **User observation is the cheapest gate** — the standing-cast
airborne rejections and the black-screen reveal were both
user-spotted minutes before harness detection.
4. **The seal finds the bypasses** — sealing the controller setter
surfaced a runtime-mutation bypass (F1) the compile-break audit could
not see; expect the same class when sealing any long-lived escape
hatch.

View file

@ -1,681 +0,0 @@
# C1 body/controller-publication writer map (2026-08-02)
Repo: `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch `codex/port-claude-agents`,
HEAD `ae296393`. READ-ONLY research; this file is the only write target.
Context read: `docs/plans/2026-08-02-placement-cutover.md` (slice C1),
`docs/research/2026-07-31-remaining-physics-campaign-handoff.md` (rejected-prototype
section, lines 143-168; prerequisite C, lines 203-221), and
`docs/research/2026-08-02-cutover-route-inventory.md` route 1 + prerequisite-C
section (lines 174-220) + route 8 (headless).
---
## 1. Every writer of `RuntimeEntityRecord.PhysicsBody`
`PhysicsBody` is `public PhysicsBody? PhysicsBody { get; private set; }`
(`src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs:72`). The ONLY mutator is
the internal method `SetPhysicsBody(PhysicsBody? body)`
(`RuntimeEntityRecord.cs:176-182`):
```
internal void SetPhysicsBody(PhysicsBody? body)
{
if (ReferenceEquals(PhysicsBody, body)) return;
PhysicsBody = body;
PhysicsOwnershipEpoch++; // <-- the ONLY place PhysicsOwnershipEpoch is bumped
}
```
So every "writer" is a caller of `.SetPhysicsBody(...)` (all 6 call sites, confirmed
by full-repo grep, zero others):
1. **`RuntimeEntityDirectory.cs:359`** — inside
`GetOrCreatePhysicsBody(RuntimeEntityRecord record, Func<incarnation,PhysicsBody> factory)`
(need exact surrounding signature — read below). Public/internal API used by
the route-1 "SECOND, narrower body-construction duplicate authority" for
non-player static-animating physics objects
(`DatLiveEntityProjectionMaterializer.cs:1003-1016`, per the route inventory).
Guard: only sets if record has no body yet (idempotent-create pattern) — see
full read below for exact guard.
2. **`RuntimeEntityObjectLifetime.cs:766`** — `Entities.SetPhysicsBody(canonical, null)`
inside a teardown method (need to confirm exact method — likely delete/retire
path, paired with `Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false)`
at line 767 in the SAME method). Clears body on deletion/teardown.
3. **`RuntimeLocalPlayerPhysicsPublicationState.cs:405`** — `candidate.Record.SetPhysicsBody(candidate.Body)`
inside `Commit(token, out activationToken)` (lines 373-411). **THIS IS THE
DORMANT OPTION-2 MECHANISM** — see section 4 below. Guarded by `IsCurrent(candidate)`
(epoch/session/identity/null-state re-check, lines 891-922) immediately before,
and by `_physics.SetPosition.PrepareDormantLocalActivationOwnership(...)` called
first (line 394) as the "seal the exact SetPosition owner before the
irreversible no-fail suffix" step — i.e. this call site DOES chain into
PrepareDormantLocalActivationOwnership per task 4's target.
4. **`RuntimeLocalPlayerPhysicsPublicationState.cs:1025`** — `_entities.SetPhysicsBody(activation.Record, null)`
inside `DiscardActivation()` (994-1032), the rollback/teardown path for the
SAME dormant mechanism — only fires if `_entities.IsCurrent(activation.Record)`
AND `ReferenceEquals(activation.Record.PhysicsBody, activation.Body)` (i.e.
never clobbers a body some OTHER newer owner already installed — the exact
anti-pattern the rejected prototype failed on).
5. **`RuntimePhysicsState.cs:1558`** — `Entities.SetPhysicsBody(record, candidateBody)`
— need full read; this is inside the remote/projectile body-binding family
(see section 3).
6. **`RuntimePhysicsState.cs:1666`** — `Entities.SetPhysicsBody(record, candidate)`
— need full read; this is the OTHER binding site, guarded by
`PhysicsBodyAcquisitionInProgress` (set true at :1645, cleared at :1676/1678).
**Writer count: 6 call sites, across 3 files** (`RuntimeEntityDirectory.cs` x1,
`RuntimeEntityObjectLifetime.cs` x1, `RuntimeLocalPlayerPhysicsPublicationState.cs` x2,
`RuntimePhysicsState.cs` x2).
## Consumers of `PhysicsOwnershipEpoch`
Only bumped in one place (`RuntimeEntityRecord.SetPhysicsBody`, above). Consumers
(all in `RuntimeLocalPlayerPhysicsPublicationState.cs`) treat it as a
compare-and-reject epoch stamped into every token/activation struct:
- `RuntimeLocalPlayerPhysicsPublicationToken.PhysicsOwnershipEpoch` (field, :53)
captured at `Prepare` time (:314).
- `RuntimeLocalPlayerPhysicsActivationToken.PhysicsOwnershipEpoch` (:70) captured
as `token.PhysicsOwnershipEpoch + 1UL` (:328) — i.e. the activation token
encodes "the epoch AFTER my own commit bumps it", so `IsActivationCurrent`
(931-962) and `IsActivationOwnershipEnvelopeCurrent` (717-745) comparing
`activation.Record.PhysicsOwnershipEpoch == activation.Token.PhysicsOwnershipEpoch`
will FAIL (reject) the instant any OTHER writer (remote/projectile bind, GC
clear, non-player static body creation via `RuntimeEntityDirectory` — none of
which should ever touch a local-player record, but the check is defense-in-depth)
touches the same record's PhysicsBody between prepare and commit.
- `IsCurrent(candidate)` (891-922, pre-Commit re-check) also compares
`candidate.Record.PhysicsOwnershipEpoch == candidate.Token.PhysicsOwnershipEpoch`
(unincremented — i.e. "nobody touched the body between Prepare and Commit").
**This IS the reentrancy defense the rejected prototype lacked** — see section 6.
## 2. The two production local-player controller constructions, end to end
### Graphical: `PlayerModeController.BuildControllerAndCamera`
`src/AcDream.App/Input/PlayerModeController.cs:244-525`. Constructor list
(52-74) shows it is injected with `RuntimeLocalPlayerMovementState controllerSlot`
(the SAME slot type headless writes) — confirms the route-inventory's "open
question" (2026-08-02-cutover-route-inventory.md:204-207): **App DOES write
`_controllerSlot.Controller = controller` directly, at line 486.** Not a
mystery/asymmetry — both hosts write the exact same public setter.
Steps, in order:
1. `_approachCompletions.BeginControllerLifetime()` (250) — App-only approach
lifecycle token.
2. Capture rollback snapshots: `_camera.CaptureState()` (255),
`_shadow.Capture()` (256) — presentation-only.
3. `new PlayerMovementController(_physics, playerRecord.ObjectClock, PlayerMovementConstructionOptions.From(_skills.Snapshot))`
(259-262) — **uses the PUBLIC constructor**, whose default publication
lifecycle is `StandalonePublished` (`PlayerMovementController.cs:617-626`),
NOT `CandidatePreparing`/`CreatePublicationCandidate`. This is the key
divergence from the dormant mechanism (section 4): this controller never
enters the `CandidatePreparing -> CandidateSealed -> RuntimeOwnedDormant ->
RuntimePublished` lifecycle at all.
4. Builds `MoveToManager`/`EntityPhysicsHost` closures over captured locals
(267-346) — presentation-adjacent glue, host-specific.
5. `EntityPhysicsHostComposition.SelectStableHostWithoutRebind` (347-350) —
canonical-state read (checks `LiveEntityRecord.PhysicsHost`).
6. `RuntimeMovementSkillProjection.ApplyTo(_skills, controller)` (366-368).
7. `ApplyStepHeights(controller, playerEntity, playerGuid)` (375) — **reads
`DatReaderWriter.DBObjs.Setup` directly** (per headless's own comment
contrasting itself, `HeadlessSessionWorldProjection.cs:685-689`) — NOT
through the prepared-collision/`IPreparedCollisionSource` seam headless
uses. Divergence #1.
8. `_controllerSlot.BeginMotionPreparation(controller, drainPriorAnimationQueue)`
(404-407) — the ONE existing narrow "preparation lease" concept already in
`RuntimeLocalPlayerMovementState` (separate from the dormant physics
publication state) that lets a synchronous PartArray/type-5 completion
reach the candidate `MotionInterpreter` before publish.
9. **Duplicate authority**`_physics.Resolve(...)` (409-413) then
`_physics.ResolvePlacement(...)` (422-430) — direct canonical-state-free
collision resolve, entirely outside `RuntimeSetPositionState`.
10. `controller.PreparePositionForCommit(...)` (434-437),
`controller.SetBodyOrientation(...)` (438).
11. Camera construction + `_camera.EnterChaseMode(...)` (440-447) —
presentation-only, but happens BEFORE the final canonical commit (445-447
precede line 482-484) — i.e. camera activation today is NOT gated on a
Runtime placement acknowledgement.
12. Re-check host stability (449-458) — throws if the host changed during
camera activation (defensive, but ad hoc — not an epoch/token check, a
bespoke `ReferenceEquals` re-read).
13. Shadow sync (`_shadow.SyncPose(...)`, 460-466).
14. `EntityPhysicsHostComposition.InstallOrRebind(...)` (472-475) + another
`ReferenceEquals` stability re-check (476-480).
15. **Duplicate authority — final commit** (482-484):
`playerEntity.SetPosition(initial.Position); playerEntity.ParentCellId =
initial.CellId; controller.CommitPreparedPosition();` — direct writes to
the App-side `WorldEntity`/render sidecar AND `controller`'s own internal
frame, bypassing any Runtime `Place` receipt or `RuntimeEntityRecord`
write. **`RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` are
NEVER touched anywhere in this method** — `controller.PhysicsBody` (the
`_body` field created in step 3) stays a private field of the
`StandalonePublished` controller; nothing calls
`Entities.SetPhysicsBody(playerRecord, controller.PhysicsBody)`. This
means TODAY the canonical `RuntimeEntityRecord.PhysicsBody` slot for the
graphical local player is **never populated at all** by this path — a
previously-unstated confirmation that `SubmitPreparedPlacement`'s
`operation.Record.PhysicsBody is not { } body` requirement (section 3)
would REJECT any ordinary (non-initial) SetPosition submitted for the
graphical local player today, because no writer ever puts a body on that
record. (Route 2's "ForcePosition" duplicate authority,
`LocalForcePositionTransaction`, works around this by mutating
`PlayerMovementController`'s own body directly via `BlipPosition`, never
touching `RuntimeEntityRecord.PhysicsBody` either — internally consistent
with each other, both equally disconnected from the canonical record.)
16. Slot commits (485-492): `_hostSlot.Host`, `_controllerSlot.Controller =
controller` (the public, unguarded setter — bumps `ControllerOwnershipEpoch`
unconditionally, see section 4), `_chase.Legacy/Retail`, `_mode.IsPlayerMode
= true`.
17. `catch`: rolls back camera + shadow only (494-518); does NOT roll back
steps 15-16 because those are the LAST lines before `lifetimeCommitted =
true` — structurally "hope nothing after this throws" rather than an
explicit no-fail invariant.
**Canonical-state mutations in this method: NONE on `RuntimeEntityRecord`**
(no `SetPhysicsBody`, no `SetFullCell`, no object-clock call) — everything
mutated is App-local (`WorldEntity`, `PlayerMovementController`'s private
body, `RuntimeLocalPlayerMovementState.Controller`,
`LocalPlayerPhysicsHostSlot`, camera, shadow). The ONLY canonical-record
writes for the local player's initial placement happen earlier in the
hydration pipeline (`LiveEntityRuntime.MaterializeLiveEntity`/
`RebucketLiveEntity`, route 1 hops 9-11) — entirely disjoint from this method.
### Headless: `HeadlessSessionWorldProjection.CreateController` + `SynchronizeLocalPlayer`
`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:566-655`
(read in full).
`SynchronizeLocalPlayer` (566-615):
1. Guards on `record.ServerGuid == _runtime.PlayerIdentity.ServerGuid` and a
present `Snapshot.Position` (568-573).
2. `_collision.CenterOn(position.LandblockId)` (575) — headless collision-
neighborhood readiness, no graphical analog.
3. `_runtime.MovementOwner.Controller ?? CreateController(record)` (576-578)
— lazy-construct-once via the SAME public `Controller` getter/setter
`RuntimeLocalPlayerMovementState` exposes; no reentrancy guard against two
concurrent calls both observing `null` (single-threaded host loop makes
this safe in practice today, not structurally).
4. `_runtime.EntityObjects.Physics.Engine.Resolve(...)` (589-594) then
`.ResolvePlacement(...)` (595-605) — **the exact same duplicate-authority
shape as graphical step 9**, hardcoded `DefaultRadius`/`DefaultHeight`
constants (visible in the call, actual values not read here) instead of
`_motionBindings.GetSetupCylinder`.
5. `controller.SetPosition(...)` + `controller.SetBodyOrientation(...)`
(610-614) — **duplicate final commit**, headless's version of graphical
step 15. Also never touches `RuntimeEntityRecord.PhysicsBody`.
`CreateController` (639-655):
1. `new PlayerMovementController(_runtime.EntityObjects.Physics.Engine, record.ObjectClock, PlayerMovementConstructionOptions.From(_runtime.CharacterOwner.MovementSkills.Snapshot))`
(642-646) — **same PUBLIC constructor / `StandalonePublished` lifecycle**
as graphical step 3.
2. `ApplySetupStepHeights(record, controller)` (649, body at 657-691) —
**reads via `_preparedCollision.ReadSetupCollision(setupId)`** (668-679),
the prepared-asset seam, NOT raw DAT — divergence #1 mirrored (headless
uses the "correct"/prerequisite-B-aligned source; graphical does not).
**Throws `InvalidDataException`** if the read status isn't `Loaded`
(672-675) — propagates uncaught up through `SynchronizeLocalPlayer` ->
`ProjectSpawn`/`ProjectPosition` -> the wire-dispatch call chain. This IS
gate 10 (late headless prepared-collision failure) manifesting today as an
unhandled exception, not a retry.
3. `RuntimeMovementSkillProjection.ApplyTo(_runtime.CharacterOwner.MovementSkills, controller)`
(650-652).
4. `_runtime.MovementOwner.Controller = controller;` (653) — **the exact
same public setter graphical step 16 uses.**
**No headless equivalent of graphical steps 1 (approach lifetime), 8 (motion
preparation lease), 11-14 (camera + host-stability re-checks), 17 (camera/
shadow rollback)** — headless has no camera/shadow/approach concept at all
(confirmed, matches the route inventory's "No headless equivalent of
graphical hops 12-14/19").
### Top divergences between the two hosts (summary)
1. **Setup/collision data source**: App reads raw DAT (`ApplyStepHeights` via
`_dats`/`_datLock`); headless reads the prepared/baked asset
(`ApplySetupStepHeights` via `IPreparedCollisionSource`). Same target
values, different pipeline — a real fidelity risk if the two ever diverge
(baking staleness).
2. **Default cylinder fallback**: App falls back to `0.48f`/`1.835f` inline
(`PlayerModeController.cs:416-420`) when `GetSetupCylinder` returns
`< 0.05f` radius; headless uses named `DefaultRadius`/`DefaultHeight`
constants at the `ResolvePlacement` call site (:599-600) — same intended
values, defined in two places.
3. **Failure handling**: App's `BuildControllerAndCamera` has an explicit
try/catch/rollback for camera+shadow; headless's `CreateController`/
`ApplySetupStepHeights` has NO surrounding try/catch — a prepared-collision
read failure is a raw unhandled exception today.
4. **Presentation surface**: App additionally owns approach-completion
lifetime, motion-preparation lease, chase camera, shadow sync — none of
which headless has or needs.
5. **Neither host touches `RuntimeEntityRecord.PhysicsBody`, `PhysicsOwnershipEpoch`,
or any `RuntimeSetPositionState` API** — both are 100% off to the side of
the canonical record, confirmed by exhaustive grep (section 1's 6 writer
call sites do not include either `PlayerModeController.cs` or
`HeadlessSessionWorldProjection.cs`).
---
## 3. Every other body binding/consumer
- **Remote dead-reckoning** (`RuntimeRemotePhysicsUpdater.cs` — flagged
protected/dirty, read-only, NOT modified): grep confirms it only READS
`record.PhysicsBody` via `ReferenceEquals(record.PhysicsBody, remote.Body)`
currency checks (line 817) — it does not call `SetPhysicsBody`. The actual
writer for remote motion is `RuntimePhysicsState.SetRemoteMotion`
(`RuntimePhysicsState.cs:1446-1570`, full read) — throws
`InvalidOperationException` on: binding-already-in-progress (1460-1464),
body-would-be-replaced when a body already exists and doesn't match
(1487-1492), losing an existing remote-placement contract (1493-1498), or
post-callback ownership drift detected via a captured
`sessionVersion`/`expectedBody`/`expectedRuntime` triple re-checked after
the bind callback (1539-1549, "changed ownership during remote-motion
binding"). Calls `Entities.SetPhysicsBody(record, candidateBody)` (:1558)
ONLY when `expectedBody is null` (first bind) via `InitializeNewPhysicsBody`
(:1556) — i.e. this is throw-on-conflict exclusivity (Option-1 flavor), not
epoch/token gating. For a LOCAL PLAYER record this path should never fire
(remote motion is for non-local entities) but the guard is defense-in-depth
and IS one of the explicit gate checks
(`!activation.Record.RemoteMotionBindingInProgress`/`RemoteMotion is null`)
the dormant local-publication mechanism re-validates at every stage
(section 4).
- **Projectile binding**: `RuntimeProjectilePhysicsUpdater.cs` similarly only
READS `record.PhysicsBody` (lines 447, 459, `ReferenceEquals` currency
checks). The writer is `RuntimePhysicsState.BindProjectile`
(`RuntimePhysicsState.cs:1309-1382`, full read) — same throw-on-conflict
shape: binding-in-progress (1343-1347), body-mismatch on rebind
(1330-1337), must-already-own-canonical-body-before-binding
(1348-1352, "projectile must borrow its canonical physics body" — i.e.
UNLIKE remote motion, `BindProjectile` requires `record.PhysicsBody` to
ALREADY be non-null and matching BEFORE it will bind — it never calls
`InitializeNewPhysicsBody`/`SetPhysicsBody` itself for a first-time body;
something else (route 5's `ProjectileController.TryBind`,
`ProjectileController.cs:176-265` per the route inventory) must construct
the body ad hoc first via a DIFFERENT path than `GetOrCreatePhysicsBody`
— worth flagging: **this is a 7th, App-side, ad hoc body-construction site
not funneled through any of the 6 canonical writer methods** — App's
`ProjectileController.TryBind` constructs a body and must be setting it onto
the record through some other route (not confirmed by this pass; App-side
`ProjectileController.cs` was not read in full — flag as open item, but it
is explicitly OUT of C1's local-player scope per the campaign handoff's
gate list item "remote and projectile binding/update" being about
*interaction with* the local-player transaction, not projectile's own
authority).
- **`RuntimeSetPositionState.SubmitPreparedPlacement`** (`RuntimeSetPositionState.cs:2224-2274`,
full read): requires `operation.Record.PhysicsBody is not { } body` to
already be true (line 2254) — i.e. EVERY non-initial-construction
SetPosition submission (ForcePosition, portal, remote Position, projectile
correction) requires a body to already exist on the record, confirming the
6 writer sites in section 1 are the exhaustive set of "who can put the
FIRST body on a record." For the local player specifically, only
`RuntimeLocalPlayerPhysicsPublicationState.Commit` (section 4) does this
today (dormant, unwired); in PRODUCTION, no writer ever populates
`RuntimeEntityRecord.PhysicsBody` for either host's local player (section
2 finding) — meaning `SubmitPreparedPlacement` would reject a local-player
submission in production today, which is consistent with the route
inventory's finding that Route 2 (ForcePosition) and Route 3 (portal) both
bypass `RuntimeSetPositionState` entirely via their own duplicate
authorities instead.
- **`RuntimeSetPositionState.PrepareDormantLocalActivationOwnership`**: see
section 4 — the ONE place the local-player-specific dormant body attach
happens; requires a pre-opened `Operation` in stage `AwaitingPreparation`
from `TryBeginExclusiveAuthoredPlacement`.
- **Object-clock epoch transitions**
(`RuntimeEntityRecord.SuspendObjectClock`/`ResetObjectClockForEnterWorld`,
both `internal`, bump `ObjectClockEpoch`): full call-site grep found BOTH
the expected `RuntimeEntityDirectory` wrapper call sites (which run
`EnsureKnown(record)` first, `RuntimeEntityDirectory.cs:311-321`) AND
**direct unwrapped calls from `src/AcDream.App/World/LiveEntityRuntime.cs`
at lines 879, 891, 897, 1258, 3030, 3033** — App calls
`record.SuspendObjectClock()`/`record.ResetObjectClockForEnterWorld(...)`
straight on the `RuntimeEntityRecord` (accessible because these are
`internal` and `AcDream.App` has `InternalsVisibleTo`), bypassing the
`RuntimeEntityDirectory` facade's `EnsureKnown` check entirely. This is
inside `LiveEntityRuntime`'s `RebucketLiveEntity`-family code (comment
references `prepare_to_enter_world`/retail `update_object`'s parent
early-out — matches the already-known route-1/prerequisite-D
`RebucketLiveEntity` duplicate authority). **Previously-unstated
implication for C1**: the SAME record whose `ObjectClockEpoch` the dormant
publication mechanism gates on can have its epoch bumped by this
direct-call path DURING the window between
`RuntimeLocalPlayerPhysicsPublicationState.Prepare` and `.Commit()`/
`.CommitActivation()` if `RebucketLiveEntity` runs concurrently for the
SAME entity (e.g. a second CreateObject/Position causing a re-rebucket
mid-construction) — the epoch check (`IsCurrent`/`IsActivationOwnershipEnvelopeCurrent`
comparing `record.ObjectClockEpoch == token.ObjectClockEpoch`) WOULD catch
and reject this correctly (fail-safe), but it confirms the gate is load-
bearing against a REAL, already-existing production writer, not a
hypothetical.
- **Deletion/teardown**: `RuntimeEntityObjectLifetime.TryAcceptDelete`
(`RuntimeEntityObjectLifetime.cs:1555+`) calls `Entities.TryDelete` then
`Entities.RemoveActive(active)` (1587) — this IMMEDIATELY flips
`Entities.IsCurrent(record)` to `false` for that record (removes it from
the active-by-guid table), which is the single check
`CanPrepare`/`IsCurrent`/`IsActivationCurrent`/every gate in section 4
depends on — so a delete landing at any point rejects the in-flight
publication transaction on its NEXT check. Full body clear happens later
in `RuntimeEntityObjectLifetime.CompleteProjectionRetirement`
(:745-771, called from `RetireCanonicalOnly`/the graphical teardown-ack
path): `Entities.SetPhysicsBody(canonical, null)` (:766) after
`Physics.SetPosition.Forget(canonical, releasePreparedMover: true)` (:753,
cancels any in-flight ordinary placement) and
`ForgetInitialCreateResidence(canonical)` (:751, cancels any in-flight
residence lease) — i.e. deletion cancels BOTH placement-lease families
before clearing the body, consistent with prerequisite E's "quiesce before
demote" discipline (though for landblock collision, not entity teardown —
the pattern rhymes).
- **`RuntimePhysicsState` per-frame body access**: `RuntimeOrdinaryPhysicsUpdater.cs`,
`RuntimeRemotePhysicsUpdater.cs`, `RuntimeProjectilePhysicsUpdater.cs` each
gate their per-tick work on `record.PhysicsBody is not { } body` /
`ReferenceEquals(record.PhysicsBody, body)` currency checks (grep-confirmed,
e.g. `RuntimeOrdinaryPhysicsUpdater.cs:68,284`) — read-only w.r.t. the
`PhysicsBody` reference itself (they mutate the BODY's internal fields
every tick, which is expected/normal simulation, not an ownership-slot
write). No workset iterates and calls `SetPhysicsBody`. `RuntimePhysicsState.cs`
itself has only two visible "workset" mentions (`ClearSpatialWorksets` at
:1951, a doc-comment at :1307) — the ordinary/remote/projectile worksets
live in their respective `RuntimeXPhysicsUpdater` files, out of this pass's
read budget beyond the grep-confirmed read-only currency pattern above.
---
## 4. `RuntimeSetPositionState.PrepareDormantLocalActivationOwnership` — nucleus or dead end?
**Definition** (`RuntimeSetPositionState.cs:1026-1052`, full read):
```csharp
internal void PrepareDormantLocalActivationOwnership(
RuntimeEntityRecord record, PhysicsBody body,
in RuntimeEntityPlacementToken token)
{
...
if (!token.IsValid
|| record.Key != token.Entity
|| !_operations.TryGetValue(token.Entity, out Operation? operation)
|| operation.Token != token
|| operation.Stage is not RuntimeEntityPlacementStage.AwaitingPreparation
|| !ReferenceEquals(operation.Record, record)
|| record.PhysicsBody is not null // <- record must have NO body yet
|| !IsCurrent(operation)
|| body.InWorld
|| (body.TransientState & TransientStateFlags.Active) != 0)
{
throw new InvalidOperationException(
"Dormant local activation must bind to the exact current placement owner.");
}
operation.Body = body;
operation.DormantLocalActivation = true;
}
```
It THROWS (does not return a status) on any invariant violation — by design a
"this should be structurally impossible if the caller validated first"
assertion, not a retryable rejection. It requires a PRE-EXISTING placement
`Operation` already opened via `TryBeginExclusiveAuthoredPlacement`
(`RuntimeSetPositionState.cs:1004-1024`) in stage `AwaitingPreparation` — i.e.
it is NOT a standalone entry point; it is ONE STEP inside a larger chain that
also needs prerequisite B's mover-preparation authority
(`IsExactPreparedPlacementCurrent`, `RuntimeSetPositionState.cs:1318-1340`)
satisfied for the SAME token/command before
`RuntimeLocalPlayerPhysicsPublicationState.CanPrepare` will even call it.
**What it was built for**: it is called from exactly ONE place in the whole
repo — `RuntimeLocalPlayerPhysicsPublicationState.Commit`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:394-397`), as the "seal the
exact SetPosition owner before the irreversible no-fail suffix" step,
immediately before `candidate.Controller.CommitRuntimeOwnership(...)` and
`candidate.Record.SetPhysicsBody(candidate.Body)`. It exists purely to make
the PLACEMENT OPERATION (owned by `RuntimeSetPositionState`) and the BODY
(owned by `RuntimeEntityRecord`) become mutually aware atomically, so that
the SAME operation can later be walked through the full retail SetPosition
staged commit (ground phase -> collision dispatch -> response -> final
commit) via `TryEvaluateDormantLocalActivation` ->
`TryPrepareDormantLocalActivationCommit` ->
`TryApplyDormantLocalActivationCommit` ->
`TryPrepareDormantLocalActivationFinalCommit` ->
`TryApplyDormantLocalActivationFinalCommit`
(`RuntimeSetPositionState.cs:2025-2077`, full read of the final-commit
method) — the LAST of which is where `_entities.SetFullCell`,
`_entities.AdvancePlacementCommit`, `body.InWorld = true`,
`_entities.SetPhysicsHost`, `controller.CommitRuntimeActivationFrame()`,
`_physics.Engine.UpdatePlayerCurrCell`, `_physics.AcknowledgeSpatialProjection`,
`_entities.ResetObjectClockForEnterWorld` (object-clock epoch bump, task 3),
and `controller.ActivateRuntimePublication()` (controller goes LIVE) ALL
happen in one synchronous, no-branch-for-failure block (:2025-2077), gated
immediately before by `IsDormantLocalActivationPrephaseCurrent`/re-validated
epoch checks. **This is genuinely the full retail SetPosition commit,
already ported, already wired to the same body/controller the dormant
publication candidate built.**
**Verdict: NUCLEUS, not a dead end** — but it is only ONE LOAD-BEARING STEP
inside a much larger, ALREADY-COMPLETE mechanism:
`RuntimeLocalPlayerPhysicsPublicationState` (1033 lines,
`src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs`)
+ its ~15 `RuntimeSetPositionState` dormant-activation methods. Constructed
once at `GameRuntime.cs:261` and exposed via
`RuntimeLocalPlayerMovementState.PhysicsPublication` (:59-61, itself
`internal`, throws if unbound). **Confirmed by exhaustive grep: ZERO
production callers in `src/AcDream.App/` or `src/AcDream.Headless/`** — the
only callers anywhere are `tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs`.
This is, functionally, **Option 2 from the rejected-prototype note ("Off-
canonical preparation followed by one validated atomic Runtime commit that
publishes the prepared controller/body relationship without copying stale
state over newer authority") already built end to end** — complete with:
- a `Prepare`/`Commit`/`Discard` triad for the BODY/CONTROLLER pair
(analogous to, and reusing, the SAME token-epoch pattern as
`RuntimeSetPositionState`'s ordinary placement operations);
- a SEPARATE `EvaluateActivation`/`CommitActivation`/`DiscardActivation` triad
for actually driving the body through retail SetPosition's staged commit
once the body/controller pair is sealed;
- re-validation of `PhysicsOwnershipEpoch`, `ObjectClockEpoch`,
`ControllerOwnershipEpoch`, `SessionLifetimeVersion`, identity
`ServerGuid`+`Revision`, and null/in-progress state for RemoteMotion/
Projectile/PhysicsHost/DeleteAcceptedForTeardown at EVERY external entry
point (`CanPrepare`, `IsCurrent`, `IsActivationCurrent`,
`IsActivationOwnershipEnvelopeCurrent`,
`IsCommittedActivationSuffixCurrent`) — this IS the reentrancy defense the
rejected snapshot-lease prototype explicitly lacked (see section 6).
**What is genuinely missing (the real C1 work), given this mechanism already
exists:**
1. Nobody calls `TryBeginExclusiveAuthoredPlacement` + prerequisite B's
`PrepareMover`/`ReadSetupCollision` chain + `PhysicsPublication.Prepare`/
`Commit`/`EvaluateActivation`/`CommitActivation` from either host — this IS
the wiring gap, exactly like every other route in the cutover.
2. **The public `RuntimeLocalPlayerMovementState.Controller` setter
(`RuntimeLocalPlayerMovementState.cs:37-50`) remains a live, unguarded
escape hatch** — both `PlayerModeController.BuildControllerAndCamera:486`
and `HeadlessSessionWorldProjection.CreateController:653` write it
directly today, and NOTHING stops either host from continuing to do so
even after C1 wires the dormant mechanism, unless that direct-write path
is deleted/sealed off (e.g. made `internal` to only
`RuntimeLocalPlayerPhysicsPublicationState`/`CommitRuntimeOwnedController`).
The setter has NO epoch/token check on write (`CanCommitRuntimeOwnedController`
is a SEPARATE, unused-by-the-setter validation method) — it will happily
accept a second unguarded assignment even while a dormant activation is
in flight, silently retiring whatever the dormant mechanism just
published (`_controller?.RetireRuntimePublication()` at :45, which is a
real no-op for anything not currently `RuntimeOwnedDormant`/
`RuntimePublished` — see section 6 gate 7). **This is the single most
important pre-existing defect C1 must close: the "exclusive" adjective in
prerequisite C's "one Runtime-owned exclusive/versioned...transaction"
is not yet true while this direct setter remains reachable from hosts.**
3. Neither `new PlayerMovementController(physics, objectClock, options)`
(public ctor, `StandalonePublished`) call site in the two hosts has been
swapped for `PlayerMovementController.CreatePublicationCandidate` — until
that swap happens, controllers built by either host never enter the
`CandidatePreparing/CandidateSealed/RuntimeOwnedDormant/RuntimePublished`
lifecycle the dormant mechanism's gates all key off of.
---
## 5. `PlayerMovementController` construction requirements
Constructor needs (from both direct-ctor call sites AND
`CreatePublicationCandidate`, `PlayerMovementController.cs:617-690`):
- `PhysicsEngine physics` (shared engine reference, both hosts pass their own
`RuntimePhysicsState`/`_runtime.EntityObjects.Physics.Engine`).
- `RetailObjectQuantumClock? objectClock` — App passes `playerRecord.ObjectClock`
(the CANONICAL record's clock, `RuntimeEntityRecord.ObjectClock` at
`RuntimeEntityRecord.cs:62`, always non-null per its field initializer);
headless passes `record.ObjectClock` identically. The dormant mechanism's
`CreatePublicationCandidate` instead passes a THROWAWAY
`new RetailObjectQuantumClock()` (`PlayerMovementController.cs:687-688`) at
construction time and only swaps in the REAL
`candidate.Record.ObjectClock` later, inside `Commit`, via
`controller.CommitRuntimeOwnership(candidate.Record.ObjectClock)`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:403-404` ->
`PlayerMovementController.cs:774-786`) — i.e. the dormant candidate is
built against a SCRATCH clock so construction can never observe or mutate
the canonical record's real clock before the atomic commit swaps it in.
This is exactly the "off-canonical preparation" half of Option 2.
- `PlayerMovementConstructionOptions` (RunSkill/JumpSkill) — both hosts build
via `PlayerMovementConstructionOptions.From(<a RuntimeMovementSkillState
snapshot>)`; the dormant mechanism's `Prepare` also takes this as a caller-
supplied parameter (`Prepare(..., PlayerMovementConstructionOptions
options, ...)`, :191) — no divergence in shape, only in WHERE the skill
snapshot is read from (App's local `_skills` field vs. headless's
`_runtime.CharacterOwner.MovementSkills` vs. the dormant mechanism taking
it as a caller parameter either way).
What construction MUTATES (beyond the private `_body`): `LocalEntityId`,
`StepUpHeight`/`StepDownHeight` (Setup-derived), `SphereList` (Setup-derived,
prerequisite B territory), `ObjectScale`, initial position/orientation via
`PreparePositionForCommit`/`SetBodyOrientation`, physics state via
`ApplyPhysicsState`, `MoveToFactory`/`PositionManager`
(`MovementManager`/`MotionInterpreter` wiring). ALL of this is exactly what
`RuntimeLocalPlayerPhysicsPublicationState.Prepare`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:187-355`) already does against
its private `CreatePublicationCandidate`-built controller, reading
`command.Physics.StepUpHeight/StepDownHeight/Spheres/Scale/Position/CellId/
CellLocalPosition/Orientation` from the CALLER-SUPPLIED
`RuntimeSetPositionCommand` (i.e. the command already carries everything
prerequisite B's mover-preparation chain produces) rather than reaching into
DAT/prepared-collision itself.
**What an "off-canonical preparation followed by one validated atomic commit"
must DEFER** (confirmed by the dormant mechanism's own design, section 4):
- The record's REAL `ObjectClock` (use a scratch clock during prep).
- `RuntimeEntityRecord.PhysicsBody`/`PhysicsOwnershipEpoch` (never touch the
canonical record during prep; only `SetPhysicsBody` inside `Commit`, and
only after `PrepareDormantLocalActivationOwnership` succeeds).
- `RuntimeLocalPlayerMovementState.Controller`/`ControllerOwnershipEpoch`
(only via `CommitRuntimeOwnedController`, never the public setter, during
prep).
- `body.InWorld`/`TransientState.Active` (explicitly forced false during prep,
`Prepare`, :292-293) — the body must not be simulatable until the LATER
activation commit flips it (`TryApplyDormantLocalActivationFinalCommit`,
`body.InWorld = true` at :2056).
- World-residence/host/shadow/camera publication (all deferred to the
activation phase / presentation-observer layer, never inside `Prepare`).
---
## 6. Adversarial gate list — exact code paths that would race TODAY
(Campaign handoff's list, `docs/research/2026-07-31-remaining-physics-campaign-handoff.md:210-221`.)
For each: what the ALREADY-BUILT dormant mechanism does (if wired) vs. what
the CURRENT production direct-construction paths do (today, unwired).
1. **Nested construction** — Dormant: `CanPrepare` requires `_activation is
null` AND `record.PhysicsBody is null` AND `_movement.Controller is null`
(`RuntimeLocalPlayerPhysicsPublicationState.cs:862-889`) — a second
`Prepare` while one is in flight is REJECTED structurally. Today: NEITHER
`BuildControllerAndCamera` NOR `CreateController` has any such guard —
`CreateController`'s `_runtime.MovementOwner.Controller ?? CreateController(record)`
(`HeadlessSessionWorldProjection.cs:576-578`) is a bare null-coalesce, not
an atomic test-and-set; only single-threaded host-loop scheduling
prevents an actual race today.
2. **Reentrant SetPosition** — Dormant: every gate re-checks
`PhysicsOwnershipEpoch`/`record.PositionAuthorityVersion` currency.
Today: `BuildControllerAndCamera`'s final mutation
(`playerEntity.SetPosition`/`ParentCellId`/`CommitPreparedPosition`,
PlayerModeController.cs:482-484) has zero epoch check — a same-thread
reentrant call (e.g. from a nested wire dispatch) would silently
clobber with no detection.
3. **Remote and projectile binding/update** — Dormant: `CanPrepare`/
`IsCurrent`/`IsActivationCurrent` all check `record.RemoteMotion is null`,
`record.Projectile is null`, `!RemoteMotionBindingInProgress`,
`!ProjectileBindingInProgress` (defense-in-depth; should never legitimately
fire for a local-player record). Today: no such check exists in either
host's direct construction path.
4. **Deletion and same-GUID new incarnation** — Dormant: `_entities.IsCurrent(record)`
checked at every gate; `TryAcceptDelete` -> `RemoveActive` flips this
immediately (section 3). Today: `BuildControllerAndCamera`/`CreateController`
take a fixed `RuntimeEntityRecord`/`WorldEntity` parameter with NO
re-validation against current canonical identity at the final commit.
5. **Projection-owner replacement** — Dormant: `_entities.SessionLifetimeVersion
== token.SessionGenerationAuthority` checked throughout. Today: no
generation check in either direct path.
6. **Object-clock epoch change** — Dormant: `ObjectClockEpoch` compared at
every gate (section 3/4). Today: no check; AND there is a REAL, live
concurrent writer already in production —
`LiveEntityRuntime.cs:879/891/897/1258/3030/3033`'s direct
`record.SuspendObjectClock()`/`ResetObjectClockForEnterWorld(...)` calls
inside the `RebucketLiveEntity` family (section 3) — this is not a
hypothetical gate, it is a currently-active call path on the SAME record
type.
7. **Reset and disposal** — Dormant: `ResetSession()`/`Dispose()` on
`RuntimeLocalPlayerMovementState` explicitly cascade into
`_physicsPublication?.ResetSession()`/`Dispose()`
(`RuntimeLocalPlayerMovementState.cs:244-295`), which tear down
candidate/activation state via `ReferenceEquals`-gated clears (never
clobbering a newer owner, `DiscardActivation`,
`RuntimeLocalPlayerPhysicsPublicationState.cs:1002-1032`). Today's direct-
construction controllers are built via the PUBLIC constructor
(`StandalonePublished` lifecycle) — **`RetireRuntimePublication()`
(`PlayerMovementController.cs:837-846`) only transitions
`RuntimeOwnedDormant`/`RuntimePublished` state; it is a NO-OP for
`StandalonePublished` controllers** — a previously-unstated finding: TODAY,
`ResetSession()`/`Dispose()`/replacing `.Controller` on either host's
directly-built controller produces NO explicit lifecycle transition at
all; the controller is simply dropped/GC'd. Not a visible bug today
(nothing reads `IsRuntimePublished` for these), but it means today's
controllers are invisible to the exact teardown bookkeeping C1's target
mechanism relies on.
8. **Commit and rollback after replacement** — Dormant: ALL validation
happens before the single canonical mutation
(`PrepareDormantLocalActivationOwnership`, which itself throws leaving
state untouched on failure); everything after is documented as
"callback-free, non-allocating, and cannot fail"
(`RuntimeLocalPlayerPhysicsPublicationState.cs:391-393`) — no rollback-
after-newer-authority path exists BECAUSE nothing after that point can
fail by construction. Today: `BuildControllerAndCamera`'s try/catch rolls
back camera+shadow only; the final `playerEntity.SetPosition`/
`ParentCellId`/`CommitPreparedPosition` triad (482-484) has nothing after
it that can throw, so it's accidentally safe today, not structurally
guaranteed.
9. **Late graphical camera/shadow/host failure** — Today: `BuildControllerAndCamera`
DOES handle this (explicit `_camera.RestoreState`/`_shadow.Restore` in the
catch block, 494-518) — this is the ONE gate the CURRENT graphical path
already handles reasonably. The dormant Runtime-side mechanism has NO
camera/shadow concept (presentation-independent by design) — C1 must
layer this handling in the PRESENTATION/observer phase (post-Runtime-
commit), matching prerequisite D's rule that a host exception must not
roll Runtime back, only retry the FIFO head.
10. **Late headless prepared-collision failure** — Today:
`ApplySetupStepHeights` (`HeadlessSessionWorldProjection.cs:657-691`)
throws a raw, uncaught `InvalidDataException` (672-675) if the prepared
Setup collision isn't `Loaded` — this propagates up through
`CreateController` -> `SynchronizeLocalPlayer` -> `ProjectSpawn`/
`ProjectPosition` with NO try/catch anywhere in between (grep-confirmed
no surrounding try/catch in `HeadlessSessionWorldProjection.cs`'s these
methods) — a genuinely unhandled-exception risk in production headless
TODAY, not just a hypothetical C1 gate.
---
## Summary for the C1 contract
- **Writer count**: 6 confirmed call sites of `RuntimeEntityRecord.SetPhysicsBody`
across 3 files (`RuntimeEntityDirectory.cs:359`,
`RuntimeEntityObjectLifetime.cs:766`,
`RuntimeLocalPlayerPhysicsPublicationState.cs:405,1025`,
`RuntimePhysicsState.cs:1558,1666`) — plus a probable 7th App-side ad hoc
projectile body-construction site not yet traced to a canonical writer
(flagged, out of local-player scope).
- The dormant `RuntimeLocalPlayerPhysicsPublicationState` +
`RuntimeSetPositionState`'s ~15 dormant-activation methods already
implement essentially the COMPLETE Option 2 transaction (off-canonical
prepare against a scratch clock/sealed candidate controller, single
validated atomic commit, full retail-staged SetPosition activation) with
epoch/token/generation/identity re-validation at every external entry
point — it has ZERO production callers in either host.
- The single largest remaining defect even AFTER wiring: the public
`RuntimeLocalPlayerMovementState.Controller` setter is an unguarded escape
hatch both hosts currently use directly; it must be sealed (made
unreachable from hosts, or itself epoch-gated) for the word "exclusive" in
prerequisite C to be true.

View file

@ -1,285 +0,0 @@
# Next-agent prompt — finish the retail placement/collision campaign
Continue acdream from the 2026-08-03 stabilization checkpoint. The code is
modern; behavior must remain retail-faithful. The campaign is playable again,
but it is **not closed**.
## Start here
Use the merged local `main` worktree:
```text
C:\Users\erikn\source\repos\acdream
```
The completed fixes originated on `codex/port-claude-agents` and are merged
into local `main`. Confirm the exact starting commit with `git rev-parse HEAD`
and read the operator's handoff message for the merge SHA. Do not reset,
clean, or overwrite the main worktree's untracked research/reference files.
The original feature worktree remains at:
```text
C:\Users\erikn\.codex\worktrees\af5e\acdream
```
That feature worktree contains protected user-local modifications and is not
the preferred continuation workspace.
Read these files in order before editing:
1. `CLAUDE.md` and `AGENTS.md`.
2. `docs/plans/2026-08-02-placement-cutover.md` — canonical placement-cutover
plan and the 2026-08-03 checkpoint.
3. This prompt.
4. `docs/research/2026-08-02-collision-throughput-handoff/implementer-progress.md`
— especially `## P1` and the final stabilization checkpoint.
5. `docs/research/2026-08-02-collision-throughput-handoff/p1-retirement-receipt-loop.md`.
6. `docs/research/2026-08-02-c3c-cutover-closeout.md`.
7. `docs/ISSUES.md` #269 and #276#280.
8. `docs/architecture/retail-divergence-register.md` rows AP-1, AP-22,
AP-131, AD-1, AD-10, AD-60, and TS-28.
`docs-drafts.md` is now explicitly historical. Do **not** apply it wholesale:
it predates the final fixes and incorrectly tries to reuse issue number #280.
## Binding rules
- Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by named
`class::method` before fresh decompilation. Retail behavior is the oracle.
- Preserve the modern Runtime-owned, presentation-independent architecture.
Graphical and headless hosts must use the same canonical gameplay owners.
- Root causes only. Do not add timeouts, grace periods, suppression flags,
catch-and-swallow paths, duplicated placement writers, or compatibility
bypasses to make a test green.
- The user's connected observations are acceptance facts. A green automated
test cannot overrule a live regression.
- Never use `git add -A`, `git add .`, `git reset --hard`, or
`git checkout -- <path>`. Stage exact paths only.
- Do not delete or normalize unrelated/untracked worktree content.
- Each independent fix must be a bisectable commit whose message records the
root cause and evidence.
- Update the divergence register and issues in the same commit that changes
their truth. Retire a row only when its exact legacy mechanism is gone.
- Do not push unless the user explicitly asks.
- Connected tests require the user's ACE server at `127.0.0.1:9000`. Close
the client gracefully before reconnecting so ACE releases the session.
## What has been completed
### C3c and collision-publication checkpoint
- `529e0e9d` — C3c production first-entry cutover for graphical and headless
hosts.
- `71604331` — O(changed) per-landblock collision publication checkpoint.
Its original commit was deliberately marked WIP after the first feel test;
do not treat that old label as the current product status. The following
fixes addressed the observed failures.
### Stabilization fixes, all user-verified where visual behavior applies
1. `01f4791e` — **retirement-receipt replay loop fixed.**
- Root cause: a pending-only live-projection bucket was promoted to a
second full landblock cleanup receipt after the first detach had already
committed. The duplicate guard threw; a broad resumable path replayed
the detach 243 times.
- Fix: retain pending identities without manufacturing another receipt;
post-commit receipt invariants are terminal, not resumable.
- Evidence: focused recenter tests; complete Release suite 10,815 passed /
4 skipped; lifecycle report
`logs/connected-world-gate-20260802-203751/report.json`; nine-stop report
`logs/connected-r6-soak-20260802-204309.report.json` with `Passed=true`,
nine checkpoints, zero failures, zero wait cues, zero pending
retirements, and no recurrence of the 243x exception.
2. `670f307c` — **remote placement and targeting share one world frame.**
- Root cause: CreateObject positions are landblock-local, but Runtime
submitted remote first-entry placement with zero world offset; the local
physics host also published a landblock-local origin to targeting.
- Fix: Runtime owns the accepted local-player world center, converts remote
Create placement before SetPosition, and publishes the local body's world
position.
- User gate: monsters and statics place correctly; monsters chase and hit
the visible player instead of attacking another coordinate.
3. `1fc529cd` — **distant Use/approach restored.**
- Root cause: Runtime object lookup is intentionally non-constructing, so a
static door/corpse could enter MoveTo without a physics host and its
target snapshot expired at the origin. Startup placement could also
leave an impossible pre-PartArray motion suffix ahead of later actions.
- Fix: ensure the canonical minimal static host before routing MoveTo and
reconcile the startup suffix exactly at presentation attach.
- User gate: near and distant object use works, including approach, turn,
and use after arrival.
4. `f24532ad` — **spell, recall, projectile, and static VFX binding fixed.**
- Root cause: C3c could create effect/projectile/static-animation sidecars
before first SetPosition had bound the entity's mesh, pose, cell, and
visibility. One-shot F754/F755 packets were lost and projectiles could
inherit a cell-less body.
- Fix: exact-incarnation presentation barrier and FIFO replay; retry
projectile/static binding on the committed visibility edge; synchronize
effect cells on rebucket.
- User gate: buffs/protections, recall effects, arrows, combat spell
projectiles, portals, and static animation all work.
5. `175ad6b0` — **login materialization acknowledgement fixed.**
- Root cause: ACE creates the local player Hidden and releases that state on
LoginComplete. Sending LoginComplete from raw F746 receipt raced first
canonical placement and left the purple haze over the character.
- Fix: one completion callback from Runtime's local first-entry terminal
edge; content-less headless retains its only truthful accepted-Create
edge.
- User gate: ordinary login no longer leaves the purple haze; recall still
has the intended materialization presentation.
### Latest focused verification
After the final fix, these passed:
- 90 focused App effect/projectile/static-animation scheduler tests.
- Two focused Runtime login-completion tests.
- The exact live-entity cell-tracking regression.
- All 79 Headless tests.
- `dotnet build AcDream.slnx -c Release --no-restore` with zero errors
(existing warnings remain).
The long complete suite and connected nine-stop soak were **not rerun after
the final four stabilization commits**. The P1 soak proves P1's binary, not
the final campaign binary.
## What remains — execute in this order
### 1. Reconcile the six selected-fixture failures
A broad selected run after cleanup exposed:
- five failures in `LiveEntityRuntimeTests`, associated with the still-open
placement/cell cutover;
- one old `RuntimeLiveEntitySessionControllerTests` remote-first-entry fixture
that provides an empty collision source while the production contract now
requires truthful collision admission.
Re-run these two classes first and record the exact test names and assertions.
Classify each as either a real product failure or a stale fixture. If stale,
update the fixture to provide the same valid prepared collision neighborhood
as production; never weaken the product contract or merely change expected
values. If real, fix the owning production mechanism and add a smaller
regression test.
Suggested first commands:
```powershell
$env:ACDREAM_PAK_PATH='C:\Users\erikn\Documents\Asheron''s Call\acdream.pak'
dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~LiveEntityRuntimeTests -m:1
dotnet test tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj -c Release --no-restore --filter FullyQualifiedName~RuntimeLiveEntitySessionControllerTests -m:1
```
### 2. Finish C4: remaining authoritative placement routes
The plan still marks routes 27 open:
- route 2: ForcePosition;
- route 3: portal placement through `RuntimeWorldTransitState` and
`RuntimePortalPlacementAuthority`;
- route 4: remote Create/Position, deleting the remaining
`RemoteTeleportController`/inline MoveOrTeleport duplicate;
- route 5: authoritative projectile correction;
- route 6: drops and split-recovery marking;
- route 7: pickup, parent-detach, and delete/recreate residue.
Inventory every current writer before editing. For each route, prove:
- one Runtime SetPosition transaction owns accepted frame, exact cell,
collision result, shadow/workset membership, and deferred-cell lifetime;
- App only projects the committed result;
- graphical and headless hosts use the same command and state path;
- stale sequences, delete/GUID reuse, missing cells, portal generations, and
replacement collision generations cannot commit old state;
- no route reconstructs from a stale spawn or uses the legacy outdoor demote/
terrain-Z lift.
Resolve #276 when the spawn settler's resolved `CellId` becomes authoritative.
Resolve #277 with a real service-window/celless lifecycle instead of relying
on ACE's current broadcast radius.
### 3. Fix #280: destination prefetch before portal reveal
Current behavior waits only a hard-coded radius-one (3x3) outdoor
neighborhood, while the visible configured world extends farther. The user
can see distant terrain continue building after portal exit.
Port the retail mechanism, not a larger magic number:
- `CellManager::PreFetchCells @ 0x00455820`;
- `LScape::PreFetchCells @ 0x00505660`;
- `CLandBlock::PreFetchCells` and `CLandBlockInfo::PreFetchCells`;
- `SmartBox::UseTime @ 0x00455410` while `blocking_for_cells`;
- the `TAS_TUNNEL_CONTINUE` resume/reveal order.
Use the quality/view-distance configured destination window. Hold one
generation-scoped reservation across terrain, buildings/statics, EnvCells,
render publication, composite textures, and collision. Keep portal UI and
wait cue responsive. Never reveal early because of a timeout, and do not wait
for an impossible terminal marker for all dynamic ACE objects.
Acceptance: repeated login, `/ls`, spell recall, and portals at every quality
setting reveal no constructing terrain, missing nearby statics/buildings,
unready interiors, missing composites, or absent nearby collision. Dynamic
monsters/items may still arrive later from ACE.
### 4. C5 closeout and live gates
After steps 13:
1. Delete every superseded placement writer and compatibility projection.
2. Run focused Runtime/Core/App tests for every route.
3. Run the complete Release solution suite with the installed pak.
4. Run the exact lifecycle/reconnect route.
5. Run the canonical nine-stop soak on the **final binary**. Read
`report.json`, not marker output. Required: `Passed=true`, zero failures,
every canonical checkpoint, `waitCueShown=false`, zero pending
publication/retirement/reveal debt, graceful exit, and no render-shadow
mismatch. Diagnose any real failure; do not rerun past it.
6. Perform two-client observation for remote creation, chase/attack, doors,
drops/pickups, portal departure/arrival, arrows, and spells.
7. Ask the user for the remaining #269/#278 slope-glide comparison at the
known impassable slope.
Only then retire AP-1, AD-1, AP-131, and the legacy half of AD-60 and close
the corresponding placement issues.
### 5. Finish the original physics-divergence campaign
After placement C5 is green:
- **AP-22:** make `ShadowShapeBuilder` the sole authority for authored Setup
collision shapes. Preserve cylinder order; use authored spheres when there
are no cylinders; cylinder-first for mixed data; truly shapeless means no
shadow. Remove invented `Setup.Radius` cylinders, `Radius * 2` heights, and
sphere-to-cylinder coercion across graphical, headless, static, and live
paths.
- **AD-10:** prove remote motion uses the full transition sweep, remove
terrain-normal preprojection, and let `CTransition::adjust_offset` project
against the actual retained contact plane. Preserve interpolation,
correction replacement, Hidden state, and network cadence.
- Run the final movement/collision matrix and update the divergence ledger,
architecture, roadmap, milestones, memory, `CLAUDE.md`, and `AGENTS.md`.
Resume vendor Slice 5 only after this campaign is genuinely closed.
## Required deliverable
For every remaining item report:
- observed failure and deterministic reproduction;
- retail/reference evidence with named functions and addresses;
- root cause in plain language plus file/line evidence;
- exact fix and why it preserves Runtime ownership;
- tests added or corrected;
- commit SHA;
- complete build/test/connected-gate numbers;
- user visual result where required;
- divergence/issue rows retired, narrowed, or left open.
Finish with an explicit list of anything still open. Do not describe the
campaign as complete while any C4 route, #280, final-binary soak, AP-22,
AD-10, or required user visual gate remains.

View file

@ -1,411 +0,0 @@
# O(changed) collision clone — design note
**Phase:** research + design only. No production edits, nothing staged, no probes left
behind. Worktree `C:\Users\erikn\.codex\worktrees\af5e\acdream`, branch
`codex/port-claude-agents`, HEAD `c52ce14a`.
**Problem:** the collision-generation staging clone is O(resident world) per landblock
publication, so loading an N-landblock ring costs O(N²). The far ring never converges.
C3c made it user-visible (late monster pop-in, extended/stuck portal space, portal-exit
pop-in, failing nine-stop soak) but did not cause it.
---
## (a) What the one-leaf-per-step invariant actually protects
### It is a frame-time bound. Nothing else.
The whole-world copy did not arrive with `6b28ff99`. It arrived one commit earlier, in
`be94bc9b` "fix(physics): activate collision generations atomically" (2026-07-31), as a
**synchronous** copy performed in a single call at admission:
```csharp
// be94bc9b, PhysicsEngine.CreateCollisionStagingCopy
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
staging._landblocks[id] = landblock;
staging.ShadowObjects.CopyCollisionStateFrom(ShadowObjects, stagingCache);
```
`6b28ff99` "make collision activation starvation-free" replaced that with
`CollisionStagingBuilder` (`src/AcDream.Core/Physics/PhysicsEngine.cs:785-941`), which
performs the *same* copy chopped into single leaves across frames. The retired AD-6 row
states the purpose verbatim
(`docs/architecture/retail-divergence-register.md:113`):
> "Admission captures the active root in O(1); a stable landblock/owner slot suffix
> materializes non-target leaves incrementally, **so resident-world size cannot become a
> synchronous clone spike**."
The committed test says the same thing three ways
(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`):
| Assertion | line | What it pins |
|---|---|---|
| `Assert.InRange(admissionAllocation, 1L, 128L*1024L)` | :973 | admission allocates a constant |
| `Assert.Equal(0, prepared.Engine.LandblockCount)` | :974 | admission copies no resident landblock |
| `Assert.InRange(step.WorkUnits, 0, 1)` | :983 | **the copy is chopped to one leaf per host step** |
| `Assert.True(advances > residentLandblocks)` | :988 | it really walked the resident world |
| `Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)` | :989 | the draft ends up holding the whole world |
So the invariant protects **hitch avoidance**: a dense resident world must not produce
one long synchronous copy inside a single update step. It is a *scheduling* property
asserted as a *mechanism*, which is why the batching lever tripped it.
### What it does NOT protect
- **Not concurrent-reader isolation.** That is `CollisionWorldStateSlot.TransferTo`'s
single `Volatile.Write` (`src/AcDream.Core/Physics/CollisionWorldState.cs:66-84`).
And a full threading audit of every writer and reader of `CollisionWorldState` found
**no concurrent reader or writer exists**: `GameWindow` runs one Silk.NET loop thread;
`UpdateFrameOrchestrator.Tick` runs `_streaming.Tick()``DrainAndApply` and then the
live/physics/camera phases strictly sequentially on that thread; the only background
workers (`LandblockStreamer` worker thread, `EnvCellRenderer` `Parallel.ForEach`,
`ObjectMeshManager` `Task.Run`) never touch `PhysicsDataCache` / `CellGraph` /
`ShadowObjectRegistry` / `PhysicsEngine` — grep of `LandblockBuildFactory.cs` and
`LandblockMesh.cs` for those types returns zero hits. Every one of
`BeginCollisionAdmission` (:2040), `PrepareCollisionGeneration` (:2092),
`AdvanceCollisionGenerationPreparation` (:2123), `StageCollisionAssets` (:2218),
`AdvanceCollisionGenerationSeal` (:2298), `CommitCollisionGeneration` (:2382),
`CancelCollisionGeneration` (:2139) passes through
`RuntimePhysicsState.EnsureCollisionMutationThread` (:2857-2869). The
`ConcurrentDictionary` choices are load-bearing only for *single-threaded*
mutate-while-enumerating (`PhysicsDataCache.cs:958-984`, and the seal cursor holding a
live enumerator across frames at :1081-1160) — the cross-thread rationale in the
`CellGraph.cs:17` and `PhysicsDataCache.cs:14-20` doc comments is **stale after
6b28ff99**.
- **Not admission fairness.** That is the separate journal/coalescing machinery
(`RuntimePhysicsState.cs:475-529`, research doc step 5) — the other half of what
"starvation-free" meant. It is orthogonal to the leaf metering and stays.
### What the pre-6b28ff99 mechanism did
`be94bc9b`'s commit was a **delta apply**, not a root swap:
```csharp
// be94bc9b, PhysicsEngine.CommitLandblockReplacement — deleted by 6b28ff99
DataCache.CommitLandblockReplacement(replacement.DataCache); // O(changed)
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
```
`6b28ff99` replaced those three lines with
`stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld)`
(`PhysicsEngine.cs:304-321`). **That is the change that made the clone load-bearing.**
Before it, the clone was a build sandbox; after it, the clone *is* the world that gets
published, so every leaf not cloned is a leaf deleted from the world.
Before `be94bc9b` the client mutated the active maps in place across many frames — the
genuinely non-equivalent state the research doc describes ("the active `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object refloods
changed at different cursors", `docs/research/2026-07-31-atomic-collision-generation.md:12-16`).
**The atomicity requirement is "the multi-frame build must not be observable", not "the
whole world must be swapped".** A delta applied inside one synchronous update-thread call
satisfies it.
### The cost is worse than F4 measured
F4 attributed median 19,736 / p90 32,135 / max 38,021 leaves and median 3.64 ms per
publication to the staging clone. The **seal** does the same walk again: the replacement
builder holds live enumerators over four `_staging` maps *and* four `_active` maps
(`PhysicsDataCache.cs:1081, 1092, 1103, 1114, 1125, 1136, 1147, 1158`) plus two in
`CellGraph.cs:184, 203`, using `CapturePrefixOne` / `CaptureRemovalOne` — a full scan of
each map to find O(target) keys. Real per-publication cost is therefore roughly **23×
resident world**, not 1×. **Fixing only the clone leaves O(N²) in the seal.** Any design
that does not also scope the removal capture is not a fix.
---
## (b) Candidate designs
### D1 — Structural sharing (persistent/immutable `CollisionWorldState`)
Replace the ~20 mutable maps with persistent maps (HAMT / `ImmutableDictionary`) so a
staging clone shares unchanged subtrees and copies only the changed path.
- **Blast radius:** every read and write site of every map in `CollisionWorldState`,
`PhysicsDataCache`, `CellGraph`, `ShadowObjectRegistry`, `PhysicsEngine`.
- **Invariant changes:** none semantically; the root swap survives unchanged, so the
atomicity story is untouched.
- **Throughput:** admission O(1), clone O(1), commit O(changed · log N). Excellent on the
copy axis.
- **Why rejected:** it pays for the copy with the *query*. A HAMT probe is several times a
`Dictionary` probe and allocates on write; the resolver runs thousands of these per
frame at 30 Hz. Slice I's entire thesis is flat, integer-indexed, zero-allocation
collision (`docs/plans/2026-07-25-modern-runtime-slice-i.md`; I1 measured 0 B/resolve).
D1 optimizes the rare operation at the expense of the hot one and fights the I-series
architecture head-on.
### D1b — Landblock-sliced root (per-prefix immutable slice + small map)
Regroup the root so each landblock's cells / flat cells / EnvCells / buildings / terrain /
outdoor cells / `LandblockPhysics` live in one immutable `LandblockCollisionSlice`, and
the root becomes `Dictionary<prefix, slice>` (~625 entries). Commit = one dictionary
write per prefix.
- **Blast radius:** every keyed read becomes mask + two probes; the seal's removal scans
collapse to "old slice vs new slice". The **shadow registry does not partition**
`ShadowEntityCells`, `ShadowEntityShapes`, `ShadowEntityRegistrations`,
`ShadowOwnerVersions` are owner-keyed and owners legitimately span prefixes (that is
the whole retained-owner problem), so the shadow half needs a separate mechanism.
- **Invariant changes:** the atomic unit becomes the slice; the root swap disappears.
- **Throughput:** O(changed) by construction, and atomic even for a hypothetical
concurrent reader.
- **Verdict:** this is the right answer *if* concurrent readers existed. They do not.
Keep it on the shelf as the migration target should the runtime ever go multi-threaded;
do not pay its refactor cost now.
### D2 — Per-landblock atomic unit: restore the delta apply *(recommended)*
`CommitLandblockReplacement` drains the **already-existing**
`PhysicsEngine.LandblockReplacementApplyCursor` (`PhysicsEngine.cs:568-777`) against the
**active** root inside one synchronous call, instead of `TransferTo`. The staging root
becomes empty-at-admission (target content only); `CollisionStagingBuilder` phases 08 are
deleted.
The delta record already exists and is already tested: `PreparedPhysicsDataCacheLandblock`
(`PhysicsDataCache.cs:1255-1270`) is exactly lists of key/value pairs to install and lists
of ids to remove, all target-scoped. The apply cursor already handles removals, installs,
terrain, the 0x40 synthesized outdoor cells, the landblock itself, and yields the reflood
owner ids to the caller at phase 12 (`PhysicsEngine.cs:702-712`). Today it is used to
rebase a committed peer delta into a *later draft*; pointing its `destination` at the
active engine is a constructor argument, not new machinery.
**What breaks, honestly:**
1. *Readers mid-query* — nothing. Single-threaded, evidenced above. A drained cursor
inside one call is indivisible with respect to every reader that exists.
2. *Re-entrancy* — real, and the audit flagged it: `OwnerMutated` /
`OwnerPrefixMembershipChanged` (`ShadowObjectRegistry.cs:86-87`) can fire mid-delta.
Precedent already exists: the commit brackets itself with
`_suppressCollisionOwnerJournal = true` (`RuntimePhysicsState.cs:2491-2501`). Extend
that bracket to cover the whole apply.
3. *Cross-frame enumerators* — the seal holds live enumerators over the **active** maps
across frames (`PhysicsDataCache.cs:1092, 1136, 1158`). A delta apply now mutates the
maps those enumerators walk. `ConcurrentDictionary` will not throw, but the observed
set is unspecified. **O1 below removes those enumerators entirely**, which is why O1
must land first.
4. *The retirement machinery*`LandblockRetirementCursor` (`PhysicsEngine.cs:348-...`)
currently retires from an off-side draft. Same cursor, destination becomes the active
root, still drained in one call.
5. *The reflood context* — the seal currently computes retained-owner refloods against a
full staging world. With an empty staging root that context is gone, so the reflood
moves to the commit call, against the now-current active world. **That is precisely
retail**: `CObjCell::init_objects` (0x0052B420) → `CPhysicsObj::recalc_cross_cells`
(0x00515A30), already the retail anchor cited on the AD-6 row.
6. *The peer-rebase / journal apparatus* — with no snapshot there is nothing to rebase.
`EnqueueCommittedRebase` (`RuntimePhysicsState.cs:563-578, 2502-2507`) and most of the
journal become dead. Delete them in the same slice; do not leave dead invariants
guarding a deleted mechanism.
- **Throughput:** per publication ≈ target payload (~70200 leaves at the measured
~184 ns/leaf) + the owners touching the target, versus today's ~23 × 20,000. Roughly
**300× less work per publication**, and — decisively — **independent of resident-world
size**, so total ring load goes O(N²) → O(N). At the failing run's numbers that is
~13.7 M leaf copies for a 625-landblock ring down to ~44 K.
### D3 — Adjacency-scoped clone (the tempting middle ground) — **rejected as unsafe**
Copy only leaves in the target's 3×3 landblock neighbourhood. One predicate change in
`CopyOneOutsideTarget` (`PhysicsEngine.cs:949-961`); clone drops ~20,000 → ~630 and
becomes O(1) in world size.
Rejected for a structural reason worth stating plainly: **while commit is a whole-root
transfer, "clone less" means "delete more."** Anything not copied into the draft is absent
from the root that replaces the world. A partial clone is therefore a silent world-erasure
bug, not a perf tuning knob. Only after commit becomes a delta does bounded context become
safe — at which point D2 has already removed the need for it. It also leaves the seal's
O(world) scans untouched, so O(N²) survives regardless.
---
## (c) Recommendation
**Take D2, in three landable slices, with O1 first.**
Rationale in one line: the delta-apply commit path is not a new invention — it is the
mechanism that shipped in `be94bc9b` and was deleted by `6b28ff99` to buy an atomicity
guarantee against concurrent readers that do not exist; restoring it makes the cost
O(changed) by construction and moves the client *toward* retail's `init_objects` shape,
not away from it.
### Invariant-test replacement
Delete from `DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep`
(`tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs:921-991`) the three
assertions that pin the clone itself — `:983` `Assert.InRange(step.WorkUnits, 0, 1)`,
`:988` `Assert.True(advances > residentLandblocks)`, `:989`
`Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount)`. They assert the exact
mechanism being removed.
Replace with `CollisionPreparationCostIsIndependentOfResidentWorldSize` — a strictly
stronger invariant, because it pins the *property* (bounded, world-size-independent work)
rather than a mechanism:
```
Run the full admission → preparation → seal → commit sequence twice,
at residentLandblocks = 32 and residentLandblocks = 256.
Assert total preparation advances(32) == total preparation advances(256) // O(changed)
Assert total seal WorkUnits(32) == total seal WorkUnits(256) // closes the seal scans
Assert every step.WorkUnits <= K // K = retained per-step bound
Assert admissionAllocation in [1, 128 KiB] // kept from :973
Assert prepared.Engine.LandblockCount == 0 after preparation completes // stronger than :974:
// the draft now holds ONLY the target
```
Add two more:
- `CommitAppliesOneLandblockDeltaInASingleCall` — the engine-mutating
`CommitCollisionGeneration` call drains the apply cursor to `Completed` before it
returns; the active world holds no target-prefix content before it and the complete
target after it, with no observable intermediate.
- `CommitTimeRefloodMatchesPrecomputedReflood` — for a fixed scenario, the owner set and
each owner's resulting cross-cell set after a commit-time reflood are **equal** to what
the pre-change staged reflood produced. This is the proof that D2 is a scheduling
change and not a semantics change, and it is the test that makes the perf framing in
(d) legitimate.
**Keep unchanged:** every `Assert.InRange(seal.WorkUnits, 0, 1)` at `:558, :667, :747,
:848, :1797, :2020, :2318, :2428, :3033` — the seal stays metered; the zero-managed-byte
commit assertions (the delta lists are built during seal, so the apply must still be
allocation-free); and `CommittedPreparationRevokesItsStagingCollisionRoot` (`:1664`) in
spirit — the staging root must still be revoked after commit, it simply no longer becomes
the active root.
### Migration plan
| Slice | Change | Gate |
|---|---|---|
| **O1** | Per-prefix installed-key ledger in `CollisionWorldState`, maintained by the install/remove paths. Rewrite the seal's ten full-map scans (`PhysicsDataCache.cs:1081-1160`, `CellGraph.cs:184, 203`) to enumerate that set. Removes the cross-frame active-map enumerators. **Behaviour-identical; a pure win that lands alone.** | existing suites green + the new seal-independence assertion |
| **O2** | `PhysicsEngine.CommitLandblockReplacement` drains `LandblockReplacementApplyCursor` against the active root instead of `TransferTo`. Extend the `_suppressCollisionOwnerJournal` bracket over the whole apply. Retirement cursor destination → active root. | focused Runtime physics suite + connected lifecycle gate |
| **O3** | Empty staging root: delete `CollisionStagingBuilder` phases 08. Move retained-owner reflood into the commit call (retail `init_objects``recalc_cross_cells`). Delete the now-dead peer-rebase/journal paths and their tests. | full ladder below |
### Gate ladder (O3 closeout)
1. **Focused:** Runtime physics collision-generation suite, App
`LandblockPhysicsPublisherTests`, Headless `HeadlessSessionHostTests`.
2. **Complete Release solution:** baseline to match or beat is **10,808 passed / 0 failed
/ 4 skips** (`-m:1`, `ACDREAM_PAK_PATH`).
3. **Connected lifecycle/reconnect gate:** signature must hold — `Passed=true`,
`Failures=[]`, both sessions `ExitCode=0`, zero render-shadow mismatches, zero pending
deltas, graceful exits.
4. **Nine-stop soak must reach `Passed: true` with `Failures: []`.** The failing run is
`logs/connected-r6-soak-20260802-143157.report.json` (37 failures, `Passed: false`);
the passing baseline is `logs/connected-r6-soak-20260727-004942.report.json`
(`Passed: true`, commit `a9a822f2`). Concrete acceptance, per checkpoint:
| Key | Failing run | Required |
|---|---|---|
| `resources.streamingWork.deferredCompletions` | 92501 at 8/9 stops | `0` at all 9 |
| `resources.streamingWork.farBacklog` | same values | `0` at all 9 |
| `resources.streamingWork.pendingPublications` | `1` at 8/9 | `0` at all 9 |
| `resources.streamingWork.deferredAdoptedCpuBytes` | 1.58.5 MB | `0` at all 9 |
| `resources.streamingWork.oldestDeferredAgeMilliseconds` | 37,76469,728 | `0` |
| `resources.loadedLandblocks` | 124533 | **625** at the eight outdoor stops |
| `reveal.waitCueShown` | `true` at 6/9 | `false` at all 9 — *this is the user-reported "extended/stuck portal space"* |
| `streamingWork.lifetimeFrameOverrunCount` | 1,706 | materially lower |
| `streamingWork.maximumOperationStage` | `"publication-index-physics"` | must no longer name this stage |
**`aerlinthe` (sequence 4) is the control, not a target.** It is the one indoor
destination and the one stop that is already clean in the failing run (374/173 vs the
baseline's 374/176, every streaming counter `0`) — precisely because an indoor
destination streams few landblocks, so O(N²) never bites. It must stay clean; do not
expect it to reach 625.
5. **Frame time must not regress — and must recover.** Route-level `cpuUs` from
`frame-history-summary.json`, microseconds:
| | p50 | p95 | p99 | p999 |
|---|---|---|---|---|
| baseline `20260727` | 9,730 | 41,262 | 44,875 | 63,474 |
| failing `20260802` | 17,001 | 47,434 | 57,061 | 102,876 |
Gate on the sharper per-checkpoint window numbers: **`checkpointWindows[].metrics.cpuUs`
p99 within +10 % of the `20260727` baseline at every stop.** The worst offenders are
`caul-plateau` 101,408 → target ≈ 47,821; `caul-return` 103,309 → ≈ 46,938;
`caul-baseline` 108,148 → ≈ 43,664; `sawato-baseline` 49,748 → ≈ 10,592. Frame count
should recover toward the baseline's 35,492 frames / 498.5 s from the failing run's
25,965 / 583.9 s.
6. **Do NOT gate on these — retest only after convergence.** `trackedGpuBytes` (62 MB
failing vs 474 MB baseline), `meshRenderData` 588 vs 607, `meshEstimatedBytes`
233.8 MB vs 268.6 MB, and the inverted CPU mesh-cache hit ratio
(3,666 hits / 5,689 misses vs 20,825 / 6,845) are all far-ring-never-converged
artifacts of the same mechanism. F4 already reached this conclusion; a leak
investigation before convergence is restored will chase a ghost.
### Register / plan bookkeeping (same commit as the code)
- **`docs/architecture/retail-divergence-register.md:113`** — the retired AD-6 row
describes the deleted mechanism verbatim ("one shared off-side `CollisionWorldState`",
"one zero-managed-byte volatile root transfer", the journal, the peer rebases). A
retired row still documents what shipped; leaving it describing a deleted clone is
exactly the out-of-sync failure the register rules forbid. Rewrite it to the delta-apply
mechanism. The row's retail anchor is already
`CObjCell::init_objects``recalc_cross_cells` (0x0052b420 / 0x00515a30) — the new
mechanism is **closer** to that anchor, so no new deviation row is created.
- **Judgment call for the implementer, do not assume:** the *original* AD-6 deviation was
"Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells`"
(`be94bc9b` register diff). If O3's commit-time reflood is again per-landblock rather
than per-cell, decide explicitly whether AD-6 must be un-retired or a successor row
added, and record the decision. Flagged, not decided here.
- **`docs/research/2026-07-31-atomic-collision-generation.md`** — steps 2, 3, 5, 6, 7, 8
and most of the "Deterministic evidence" list describe the clone / journal / rebase.
Rewrite in the same commit.
- **`memory/project_collision_port.md`** — the 37-line block `6b28ff99` added is now wrong.
- **`claude-memory/project_physics_collision_digest.md`** — add two DO-NOT-RETRY entries:
(1) *"Do not re-introduce a whole-world staging clone. The atomic unit is the landblock
delta applied in one update-thread call; the runtime is single-threaded and the root
swap buys nothing."* (2) *"Batching N leaves per staging step is not a fix — measured
1.8× at N=256, not convergence, and it trips the committed invariant test."*
- **`docs/ISSUES.md`** — F4 established this regression is not in the C3c diff, so it
needs its own issue id (the C3c smoke-test commit `c52ce14a` filed #279 for a different
finding). File it, and reference it from the O1/O2/O3 commit messages.
- **Rollback:** each slice lands as one commit with its own recorded `git revert` SHA,
per the Modern Runtime convention.
---
## (d) Perf-work framing
**This is modern-runtime infrastructure, not retail-scoped behaviour work.** The delta
apply installs the *identical* `PreparedPhysicsDataCacheLandblock` content that
`TransferTo` publishes today — the same cells, flat cells, EnvCell topology, buildings,
terrain, synthesized outdoor cells, landblock, and owner set. Only the path by which that
content reaches the active root changes, and only the amount of work done to get there.
Collision results, contact planes, walkable polygons, membership, and therefore game feel
are bit-identical.
The project's render-perf-not-faithfulness-gated rule
(`claude-memory/feedback_render_perf_not_faithfulness_gated.md`) applies: throughput work
that is pixel- and feel-identical does not need a retail-behaviour gate. But because this
is collision, the acceptance bar is still the connected gates plus the user's visual pass
— green unit tests prove nothing about a streaming convergence bug.
Two guards keep the framing honest:
1. **The direction of travel is toward retail, not away.** Retail hydrates a cell
synchronously in `CObjCell::init_objects` and refloods the objects associated with it
via `recalc_cross_cells`. A per-landblock delta applied in one update-thread call is
the streaming-shaped version of exactly that. The whole-world clone was the adaptation;
removing it retires an adaptation rather than adding one.
2. **The one thing that could change feel is reflood timing** — owners near the target
re-flooding at commit rather than from a pre-computed staged set.
`CommitTimeRefloodMatchesPrecomputedReflood` (above) is the specific test that turns
that from an assumption into evidence. If that test cannot be made to pass, the perf
framing is void and the slice needs a behaviour gate.
**Explicitly not a workaround.** Per the no-workarounds rule, note what this is *not*: no
suppression flag, no grace period, no budget loosening, no early-return guard at the
symptom. The root cause is an algorithm that is quadratic in resident-world size, and the
fix is to make it linear by restoring the per-landblock atomic unit the mechanism had
before `6b28ff99`.
### Measure before and after
A stripped-after probe should count, per publication: (clone leaves, seal leaves, apply
leaves) and wall-clock for each. F4 measured only the clone (median 19,736 / p90 32,135 /
max 38,021 leaves, median 3.64 ms, 1,584 preparations = 8.53 s CPU in one 4-minute capped
session). The seal was never measured and D2 must beat both. Expected after O3: clone
leaves 0, seal leaves ≈ target payload, apply leaves ≈ target payload, total per
publication well under 100 µs and flat as the ring fills.

View file

@ -1,118 +0,0 @@
# Docs-commit drafts — collision publication-throughput fix (O1/O2/O3)
> **HISTORICAL DRAFT — DO NOT APPLY WHOLESALE (2026-08-03).** The O1/O2/O3
> implementation landed in `71604331` and the user-visible stabilization
> fixes continued through `175ad6b0`. This draft predates that work, assigns
> issue number #280 to the collision clone even though #280 now canonically
> tracks incomplete portal-destination prefetch, and names ledger edits that
> must be re-audited against the final production tree. It remains only as
> research evidence. Use `NEXT-AGENT-PROMPT.md`, the campaign plan, and the
> live divergence register for current work.
Drafted per contract; NOT applied to the repo. Apply in the docs commit after
code review. Register judgment executed as pinned: AD-6 stays retired with a
successor note; the residual timing/order compression gets a NEW row (AD-62).
---
## 1. `docs/architecture/retail-divergence-register.md`
### 1a. Append to the retired ~~AD-6~~ row (line 113), at the end of column 2
> **Successor note (2026-08-02, collision publication-throughput fix
> O1/O2/O3):** the whole-world staging clone, the owner-mutation journal, the
> peer-rebase/retirement cursors, and the zero-managed-byte whole-root
> transfer this row describes were deleted. The shipped mechanism is now the
> per-landblock delta commit this row's retail anchor always pointed at:
> admission captures an O(1) empty target-only staging root
> (`PhysicsEngine.CollisionStagingBuilder`), the seal enumerates one prefix's
> installed keys through the `CollisionWorldState` per-prefix ledgers, and
> `PhysicsEngine.CommitLandblockReplacement` drains the sealed delta into the
> ACTIVE root in one synchronous update-thread call, recalculating every
> associated owner's cross-cells against the live world
> (`ShadowObjectRegistry.ApplyCommittedOwnerReplacement` +
> `RefloodPrefixOwnersAfterReplacement`; retail `CObjCell::init_objects`
> 0x0052b420 → `CPhysicsObj::recalc_cross_cells` 0x00515a30). Equivalence is
> pinned by `CommitTimeRefloodMatchesPrecomputedReflood`; world-size
> independence by `CollisionPreparationCostIsIndependentOfResidentWorldSize`.
> Residual timing/order compression vs retail: AD-62.
### 1b. New row AD-62 (residual timing/order compression), adaptation class
| AD-62 | **Adaptation.** Commit-time collision reflood granularity/order: retail runs `CObjCell::init_objects` per CELL at cell hydration and `CPhysicsObj::recalc_cross_cells` per object as each cell loads; acdream runs the equivalent once per LANDBLOCK replacement inside the single synchronous activation call, walking the sealed owner list then the live prefix-owner slots (per-landblock granularity matches the streaming unit, same compression `ShadowObjectRegistry.RefloodLandblock` has always carried). An owner becoming target-associated mid-publication refloods at activation (the prefix-slot sweep) rather than at its own cell's hydration instant; a stationary owner adjacent to the target whose flood would only change through building/EnvCell bridges can carry frame-stale cross-cells between the seal capture and the activation sweep (movers self-heal per `SetPositionInternal`). | `src/AcDream.Core/Physics/PhysicsEngine.cs` (`CommitLandblockReplacement`); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`ApplyCommittedOwnerReplacement`, `RefloodPrefixOwnersAfterReplacement`) | Late/stale cross-cell rows for a non-moving seam object for a few frames around a landblock publication — an object collidable through a wall seam or briefly not collidable where new topology landed | Low | `CObjCell::init_objects` 0x0052b420; `CPhysicsObj::recalc_cross_cells` 0x00515a30; `CPhysicsObj::SetPositionInternal` tail 0x00515330 |
---
## 2. `claude-memory/project_physics_collision_digest.md` — DO-NOT-RETRY additions
> - **Do not re-introduce a whole-world staging clone for collision
> generations.** The atomic unit is the landblock delta applied in one
> update-thread call (`PhysicsEngine.CommitLandblockReplacement`); the
> runtime is single-threaded and a root swap buys nothing. The clone made
> ring load O(N²) (the C3c late-monster-pop-in / stuck-portal-space soak
> failure, issue #280). Deleted 2026-08-02.
> - **Batching N staging-clone leaves per step is not a fix** — measured 1.8×
> at N=256, not convergence, and it trips the committed one-work-unit seal
> invariant. The fix was removing the clone, not tuning it.
## 3. `docs/research/2026-07-31-atomic-collision-generation.md` — update
Add a banner at the top:
> **SUPERSEDED IN PART (2026-08-02).** Steps 2 (whole-world staging clone), 3
> (owner-mutation journal write-through), 5 (journal coalescing/compaction), 6
> (peer rebases), 7 (draft retirement cursors), and 8 (whole-root transfer)
> describe machinery deleted by the collision publication-throughput fix
> (O1/O2/O3). The atomicity requirement they served — "the multi-frame build
> must not be observable" — is now met by one synchronous per-landblock delta
> apply under prefix quiescence with commit-time owner refloods against the
> live world (retail init_objects → recalc_cross_cells). The admission
> fairness half (quiescence, prefix mutation permissions, ordered activation)
> is unchanged and still accurate. Deterministic-evidence entries that name
> the journal/rebase/retirement tests refer to tests deleted with the
> machinery; their replacements are
> `CollisionPreparationCostIsIndependentOfResidentWorldSize`,
> `CommitAppliesOneLandblockDeltaInASingleCall`, and
> `CommitTimeRefloodMatchesPrecomputedReflood`.
## 4. `memory/project_collision_port.md`
Remove/replace the 37-line block `6b28ff99` added (the starvation-free clone
description) with a pointer to the new mechanism (same content as 1a).
## 5. `docs/ISSUES.md` — file the regression as its own issue
> - **#280 — OPEN → fixed pending review: collision staging clone made ring
> load O(N²)** (filed 2026-08-02). The per-publication whole-world staging
> clone (be94bc9b synchronous, 6b28ff99 metered) plus the seal's full-map
> scans cost ~2-3× resident world per landblock publication, so an
> N-landblock ring cost O(N²) and the far ring never converged. F4 measured
> median 19,736 leaves / 3.64 ms per publication; C3c made it user-visible
> (late monster pop-in, extended/stuck portal space, portal-exit pop-in,
> nine-stop soak failure 20260802-143157) but did not cause it. Fix: O1
> per-prefix installed-key ledger; O2 per-landblock delta commit
> (restores be94bc9b's O(changed) apply); O3 empty staging root +
> commit-time reflood (retail init_objects → recalc_cross_cells) + journal/
> rebase/retirement machinery deleted. Reference the O1/O2/O3 commits here
> when they land.
## 6. Milestones/roadmap
No phase-table change needed: this is Modern Runtime infrastructure follow-up
inside the active campaign context; the C3c smoke-test findings list in
ISSUES (#278 additions) should get items (d)/(e)/(f)-class re-observed after
the soak gate passes.
## 7. Commit-message notes for the slice commits
- O1: `fix(physics): #280 O1 - per-prefix installed-key ledger; seal scans and
landblock removals become O(prefix keys)` — behavior-identical; new test
CollisionSealWorkIsIndependentOfResidentWorldSize.
- O2: `fix(physics): #280 O2 - restore per-landblock delta commit (be94bc9b
shape) at PhysicsEngine.CommitLandblockReplacement` — notes: staging-slot
owner-list widening (direct-staged owners), staging-root revoke, zero-byte
commit asserts → O(target payload) bounds (commit-time reflood + dictionary
node inserts allocate; world-size independence pinned by the O3 test).
- O3: `fix(physics): #280 O3 - empty staging root; commit-time reflood
(init_objects → recalc_cross_cells); delete journal/rebase/retirement
machinery` — 9 mechanism tests deleted, 2 contract tests added.

View file

@ -1,68 +0,0 @@
# Grep sweep — deleted collision machinery (2026-08-02, against the WIP O1-O3 tree)
Produced by an adversarial-review subagent (completed after the review
round was halted). Verdict summary: the deletions are CLEAN — no
surviving consumer of the journal/peer-rebase/draft-retirement/staging-
clone machinery, and no post-`Revoke()` dereference path found. Two
actionable leftovers and a stale-docs catalog for whoever lands the
collision work.
## Actionable
1. `CollisionWorldStateSlot.TransferTo` (`CollisionWorldState.cs:270-281`)
is fully DEAD — zero callers incl. tests. Delete it (comments at
`PhysicsEngine.cs:311/:379` reference it historically and are
accurate).
2. Two test names are stale terminology with live bodies:
`RuntimePhysicsStateTests.cs:1730`
(`PostCommitOwnerMutationWinsOverQueuedPeerRebase`) and `:2264`
(`PendingOrActivePeerRebaseCannotResurrectRetiredLandblock`) — rename
when touched.
## Confirmed DEAD (zero refs in src/tests/tools/docs)
- `LandblockRetirementCursor`/`Step`/`CreateLandblockRetirementCursor`
- `CollisionStagingBuilder.Advance/.WorkUnits/.Completed/.SuppressLandblock`
- `CopyOneOutsideTarget`
- Owner journal: `_collisionOwnerJournal`, `EnqueueCommittedRebase`,
write-through, draft retirement
## Confirmed LIVE (name overlap, different concept — do not "clean up")
- `InstallLandblockClone` (`PhysicsEngine.cs:576,673`) — the per-landblock
delta-apply installer, not the old clone loop.
- `MirrorOwnerFrom` (`ShadowObjectRegistry.cs:1961,2011,2079`) —
repurposed for the O3 commit-time reflood.
- `OwnerMutated`/`OwnerPrefixMembershipChanged` events — general-purpose,
unrelated to the deleted journal.
- `RetryDeferred` (`RuntimeSetPositionState.cs:4103` et al.) — the
deferred-SetPosition subsystem, unrelated.
- `LandblockReplacementBuilder` `.WorkUnits/.Advance/.Completed` — the
live seal builder, not the deleted staging builder.
## Post-Revoke audit
`Revoke()` has exactly one call site (`PhysicsEngine.cs:381`, end of
`CommitLandblockReplacement`). `MarkCommitted` (`RuntimePhysicsState.cs:
372-380`) + `LandblockPhysicsPublisher.cs:505/:1177-1183` guards mean no
production or test site dereferences a revoked slot. `prepared.Engine`/
`DataCache` remain unguarded by design (ObjectDisposedException is the
intended revoked behavior).
## Stale docs/comments that now describe the DELETED design (rewrite when
landing the collision work)
- `docs/architecture/acdream-architecture.md:504-566` — full section on
journal/write-through/rebase/root-transfer: STALE, needs rewrite to
the per-landblock delta commit.
- `memory/project_collision_port.md:50-88` — same content class, STALE.
- `docs/research/2026-07-31-atomic-collision-generation.md` — whole file
documents the deleted mechanism with no supersession note (the
prepared banner is in docs-drafts.md).
- `src/AcDream.Core/Physics/PhysicsDataCache.cs:126-130` XML doc —
describes the deleted one-leaf-per-step materialization.
- `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:621-625`
comment — "zero-work root transfer" no longer true.
- Correctly archival (no action): `retail-divergence-register.md:113`
(~~AD-6~~ retired entry); the placement-cutover plan + C3c closeout
(they name the clone as the known problem being fixed).

View file

@ -1,95 +0,0 @@
# P1 — origin-recenter retirement-receipt loop
## Observed failure
`launch-feeltest-oclone.log` contains 243 consecutive failures with this
shape:
```text
streaming: origin-recenter preparation will resume:
InvalidOperationException: Landblock 0xC85AFFFF already has a full
retirement receipt.
```
The stack is `StreamingController.TryAdvanceOriginRecenterPreparation`
`LandblockPresentationPipeline.DetachAllForOriginRecenter`
`LandblockRetirementCoordinator.AdoptDetachedFull`.
## Root cause
An ordinary full retirement detaches every landblock-owned presentation
resource first, then parks surviving live entities in
`GpuWorldState._pendingByLandblock` while the exact cleanup ticket advances
asynchronously (`GpuWorldState.DetachLandblock`, around lines 11881314).
The origin-recenter swap incorrectly treated every pending-only live bucket
as another landblock presentation generation (`GpuWorldState.cs`, former
lines 13521353). It therefore emitted a second full cleanup receipt for the
same already-retired generation. `LandblockRetirementCoordinator` correctly
rejected that duplicate at lines 416425. Because spatial detachment had
already committed, the broad retry catch in
`StreamingController.TryAdvanceOriginRecenterPreparation` then repeated the
detach against the changed state every frame.
The pre-fix regression test
`OriginRecenterAdoption_PendingOnlyLiveProjectionDoesNotCreateSecondFullReceipt`
failed because the recenter returned one receipt for the pending-only bucket.
## Retail and reference boundary
Retail destroys one concrete landblock owner synchronously:
`CLandBlock::destroy_static_objects` (`0x0052FA50`) leaves and deletes the
landblock's static objects; `CLandBlock::Destroy` (`0x0052FAA0`) releases its
buildings and landblock data; `CLandBlock::release_all` (`0x0052FCF0`)
releases the landblock's object and visibility ownership. A live object
parked outside a loaded landblock is not a second `CLandBlock` and therefore
cannot create a second landblock-destruction transaction.
The extracted WorldBuilder reference follows the same ownership boundary:
`ObjectRenderManagerBase` removes an actual `_landblocks` entry before
`UnloadLandblockResources`, and `PortalRenderManager` only unloads a removed
`PortalLandblock`. Neither treats an independently parked object as a new
landblock resource owner.
Acdream retains its approved asynchronous adaptation: the first exact
receipt owns cleanup, while the live projection survives spatial recentering.
## Fix
- `GpuWorldState.DetachAllForOriginRecenter` no longer creates retirement
receipts from `_pendingByLandblock` alone. Pending live identities are
still captured from `_projectionLocations`, cleared atomically, and
re-parked unchanged.
- A landblock that also owns loaded, pending-render, pending-near, tier, or
bounds state still receives its exact full receipt.
- A receipt-ledger invariant thrown after spatial detachment is now surfaced
as a committed `StreamingMutationException`; it is terminal rather than
falsely logged as resumable work.
- The genuine duplicate-receipt guard remains unchanged.
## Deterministic evidence
- The new pending-only regression failed before the source fix and passes
afterward.
- `OriginRecenter_PendingOnlyLiveProjectionKeepsItsExistingRetirementOwner`
drives the production recenter/controller sequence and proves the origin
commits while the first cleanup ticket remains pending.
- `OriginRecenter_CommittedReceiptInvariantFailsFastInsteadOfReplayingDetach`
proves a genuine post-detach ledger violation surfaces once rather than
entering a frame-by-frame retry loop.
- The complete `OriginRecenter` focused group passes 20/20.
## Gate evidence
- Release build: 0 errors (21 pre-existing warnings).
- Complete Release suite: 10,815 passed, 0 failed, 4 skipped.
- Connected lifecycle/reconnect gate:
`logs/connected-world-gate-20260802-203751/report.json``Passed=true`.
- Connected nine-stop soak:
`logs/connected-r6-soak-20260802-204309.report.json``Passed=true`,
`Failures=[]`, graceful exit, all 9 canonical checkpoints present, no wait
cue, no pending landblock retirement, no reveal invariant failure, and no
render-shadow mismatch.
- The soak artifacts contain zero occurrences of
`already has a full retirement receipt`; the captured failing session had
243.

View file

@ -1,30 +0,0 @@
# User feel-test observations — O-slice tree (2026-08-02 ~20:10, uncommitted)
Axioms; they override the gate numbers. Log: launch-feeltest-oclone.log.
1. MONSTERS STILL POP IN while running past — the O-slice did NOT fix the
user-visible symptom despite the soak's publication convergence.
2. MONSTERS SPAWNED MID-AIR far ahead at a newly-entered area.
3. STATICS ("stabs") PLACED INCORRECTLY — visibly wrong static placement.
4. User: "This is not how retail worked. I could see monsters way in
front of me."
5. DOOR APPROACH REGRESSED: using a door no longer walks the character
to it first. NOTE: likely a COMMITTED C3c regression, not O-slice —
prime suspect is PlayerModeController's conditional MoveTo bind
(`if (controller.MoveTo is { } moveTo)` — the flip only binds
approach callbacks IF the Runtime-owned MoveToManager already exists;
the legacy path CREATED it via factory at attach). If Runtime's
MakeMoveToManager runs after player-mode attach (or never for this
flow), MoveToComplete/approach never wires. Triage first in the C3c
fix slice; check whether the 175401 gate probe ever exercised a
door/use-approach (suspect: no coverage).
SMOKING GUN (log): 243x "streaming: origin-recenter preparation will
resume: System.InvalidOperationException: Landblock <id> already has a
full retirement receipt." — continuous catch-retry loop during origin
recenter. Both reviewers redirected with this; the implementer's
"exposed pre-existing" retirement classification is under re-judgment.
The catch-and-resume wrapper is itself suspect as a pre-existing
symptom-swallower (no-silent-catch rule).
STATUS: O-slice commit ON HOLD until every observation is explained.

View file

@ -1,29 +0,0 @@
# User in-game observations — 2026-08-02 (~15:40, during the F4 diagnostic run)
Axioms per the retail-oracle rule. User will do a full test session once
the current work passes; these are the pre-session signals.
1. AIRBORNE-WHILE-STANDING (severe, flip-suspect): repeated
"[System] You can't do that while in the air!" +
"You can't do that. (error 0x042C)" x4 + one "WeenieError 0x001D" when
trying to cast while standing still. Suspect: the conductor placement
path lacks the legacy spawn path's #270 settle sweep (contact from the
compressed first gravity frame) -> outbound contact state says
airborne. Routed to F4 as a lead (unified-hypothesis check); if F4's
stuck item is not the player, this becomes its own slice (F5) BEFORE
the C3c commit — casting is core gameplay and blocks the smoke test.
2. MATERIALIZATION HAZE RE-FIRING while standing still (flip-suspect):
purple haze re-triggers around the character. Plausibly the visible
face of the soak's pendingPublications=1 stuck item if that item is
the local player. Routed to F4.
3. NO SLIDE ALONG IMPASSABLE SLOPES: walking into too-steep terrain does
not glide laterally. Likely pre-existing open issue #269 (Campaign P
slope-slide residual). Verify pre-existence during the review/closeout;
do not fold into C3c unless evidence says the flip changed it.
4. /ls DOES NOT WORK: unclear which command surface (chat slash command?).
Triage at the session; low priority.
Review-focus implication: retail reviewer must verify the flip preserves
the legacy spawn path's contact seeding (#270) semantics; adversarial
reviewer must verify the placement publication for the local player
actually completes and is reaped.

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