From f7aa8e0eb7fdfbc453d911699981b052fae2352d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 28 Aug 2026 20:01:39 +0200 Subject: [PATCH] fix: complete retail parity stability pass --- README.md | 3 +- docs/ISSUES.md | 952 +++++++++++++----- .../retail-divergence-register.md | 26 +- docs/launch-options.md | 15 +- docs/plans/2026-07-27-vulkan-campaign.md | 10 + ...2026-08-09-chat-retail-command-registry.md | 6 + docs/research/2026-08-17-map-house-recon.md | 21 + .../2026-08-28-issue178-cell-shell-culling.md | 68 ++ .../2026-08-28-open-issue-validity-audit.md | 141 +++ ...08-28-owner-closed-issue-validity-audit.md | 108 ++ .../Composition/FrameRootComposition.cs | 6 +- .../InteractionRetainedUiComposition.cs | 24 +- .../Composition/SessionPlayerComposition.cs | 1 + .../Net/LiveSessionCommandRouter.cs | 88 +- .../Net/LiveSessionRuntimeFactory.cs | 67 +- src/AcDream.App/Net/RetailSkillFormula.cs | 89 +- .../LiveEntityNetworkUpdateController.cs | 103 +- .../Rendering/InteriorEntityPartition.cs | 56 +- .../Rendering/LiveEntityAnimationPresenter.cs | 51 +- .../Rendering/RetailPViewRenderer.cs | 4 +- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 20 +- .../Rendering/SkyPesFrameController.cs | 16 +- .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 31 +- .../Rendering/WorldRenderFrameBuilder.cs | 14 +- src/AcDream.App/RuntimeOptions.cs | 16 +- .../Settings/RuntimeSettingsController.cs | 44 +- src/AcDream.App/UI/ClientCommandController.cs | 228 ++++- .../UI/Layout/CharacterCreationSkillsPage.cs | 38 +- .../Layout/CharacterCreationUiController.cs | 34 - .../Layout/CharacterManagementUiController.cs | 54 +- .../CharacterManagementUiMountCoordinator.cs | 12 +- .../UI/Layout/ConfigOptionsPageController.cs | 33 +- .../UI/Layout/CreditsUiController.cs | 433 ++++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 14 +- src/AcDream.App/UI/Layout/ElementReader.cs | 68 +- .../UI/Layout/HousePageController.cs | 54 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 59 +- .../UI/Layout/RetailTooltipPresenter.cs | 9 +- src/AcDream.App/UI/Layout/UiDatElement.cs | 45 +- .../UI/Layout/VendorUiController.cs | 26 +- src/AcDream.App/UI/RetailUiRuntime.cs | 203 +++- src/AcDream.App/UI/UiButton.cs | 20 + src/AcDream.App/UI/UiElement.cs | 13 +- src/AcDream.App/UI/UiMenu.cs | 85 +- src/AcDream.App/UI/UiText.cs | 10 +- .../InventoryWorldDropProjectionController.cs | 14 +- src/AcDream.App/World/LiveEntityRuntime.cs | 19 +- .../Messages/ClientCommandRequests.cs | 200 +++- .../Transport/AckNakScheduler.cs | 1 + .../Transport/OutboundFlowQueue.cs | 17 + .../Transport/ReliableTransport.cs | 21 +- .../Transport/RetailPacketLossAverager.cs | 124 +++ .../Transport/TransportStats.cs | 14 + src/AcDream.Core.Net/WorldSession.cs | 228 ++++- src/AcDream.Core/Audio/DatSoundCache.cs | 51 +- src/AcDream.Core/Chat/ChatLog.cs | 20 +- .../Physics/PhysicsTimestampGate.cs | 11 +- .../Physics/RetailAnimationCyclePlayback.cs | 37 +- src/AcDream.Core/World/SkyState.cs | 9 + .../Hosting/HeadlessSessionHost.cs | 92 +- src/AcDream.Runtime/Chat/ClientCommandId.cs | 54 +- .../RetailAdministrationCommandDispatcher.cs | 597 +++++++++++ .../Chat/RetailClientCommandCatalog.cs | 243 ++++- .../Chat/RetailCommandHelpTable.cs | 14 +- .../Entities/InboundPhysicsStateController.cs | 49 +- .../Gameplay/RuntimeHouseState.cs | 228 ++++- ...imeAuthoritativePositionRouteClassifier.cs | 56 +- .../Physics/RuntimeSetPositionState.cs | 102 +- .../Session/LiveSessionEventRouter.cs | 15 +- .../Panels/Settings/DisplaySettings.cs | 7 +- .../RuntimeOptionsSessionConfigTests.cs | 27 + .../LaunchOptionsDocumentationTests.cs | 5 +- .../Net/LiveSessionCommandRouterTests.cs | 80 +- .../Net/RetailSkillFormulaTests.cs | 46 +- ...ityNetworkOnPositionCollapseMatrixTests.cs | 31 +- .../LiveEntityOrdinaryPhysicsUpdaterTests.cs | 16 +- .../Rendering/InteriorEntityPartitionTests.cs | 32 + .../LiveEntityAnimationPresenterTests.cs | 44 + .../Rendering/Sky/RainAnimationClockTests.cs | 39 + .../Rendering/SkyPesFrameControllerTests.cs | 100 +- .../Rendering/Wb/EnvCellRendererTests.cs | 14 + .../Rendering/WorldRenderFrameBuilderTests.cs | 50 + .../RuntimeOptionsRetailUiTests.cs | 17 +- .../RuntimeSettingsControllerTests.cs | 34 + .../UI/ClientCommandControllerTests.cs | 410 +++++++- .../Layout/CharacterCreationLiveDatTests.cs | 4 +- .../CharacterCreationUiControllerTests.cs | 2 +- .../CharacterManagementUiControllerTests.cs | 30 +- .../ConfigOptionsPageControllerTests.cs | 25 +- .../UI/Layout/CreditsLiveDatTests.cs | 110 ++ .../UI/Layout/CreditsUiControllerTests.cs | 166 +++ .../UI/Layout/DatWidgetFactoryTests.cs | 15 +- .../UI/Layout/ElementReaderTests.cs | 82 +- .../LayoutImporterInvisibleSweepTests.cs | 106 ++ .../UI/Layout/LayoutImporterTests.cs | 23 + .../UI/Layout/MapHousePanelControllerTests.cs | 52 +- .../Layout/OptionsPanelLiveMountProbeTests.cs | 10 +- .../Layout/SpellcastingUiControllerTests.cs | 7 + .../UI/Layout/UiDatElementTests.cs | 13 +- .../UI/Layout/VendorUiControllerTests.cs | 6 +- tests/AcDream.App.Tests/UI/UiButtonTests.cs | 59 ++ tests/AcDream.App.Tests/UI/UiMenuTests.cs | 55 +- .../LiveEntityHydrationControllerTests.cs | 11 +- .../World/LiveEntityTestFixture.cs | 5 +- .../RuntimePlacementPresentationSinkTests.cs | 42 + .../MeshExtractorSolidFaceExtractionTests.cs | 80 ++ .../Messages/ClientCommandRequestsTests.cs | 124 +++ .../Transport/AceSessionModel.cs | 6 + .../Transport/LossyTransportDecoratorTests.cs | 19 +- .../OutboundReliableTransportTests.cs | 23 + .../RetailPacketLossAveragerTests.cs | 77 ++ .../WorldSessionAdministrationCommandTests.cs | 95 ++ .../Audio/DatSoundCacheTests.cs | 57 +- tests/AcDream.Core.Tests/Chat/ChatLogTests.cs | 29 +- .../Physics/MotionSequenceGateTests.cs | 19 +- .../RetailAnimationCyclePlaybackTests.cs | 11 +- .../HeadlessSessionHostTests.cs | 84 ++ ...ailAdministrationCommandDispatcherTests.cs | 128 +++ .../InboundPhysicsStateControllerTests.cs | 72 +- .../Gameplay/RuntimeHouseStateTests.cs | 158 ++- ...thoritativePositionRouteClassifierTests.cs | 12 +- .../RuntimeCollisionPrefixQuiescenceTests.cs | 52 +- .../Physics/RuntimeSetPositionStateTests.cs | 74 ++ .../Session/LiveSessionEventRouterTests.cs | 120 +++ ...imeAcceptedPositionDriveControllerTests.cs | 20 +- .../Panels/Chat/ChatCommandRouterTests.cs | 27 +- .../Chat/RetailClientCommandCatalogTests.cs | 102 +- .../Chat/RetailCommandHelpTableTests.cs | 7 +- .../RetailCommandRegistryConformanceTests.cs | 16 +- tools/A8CellAudit/Program.cs | 129 ++- tools/PesChainAudit/Program.cs | 5 + 131 files changed, 7765 insertions(+), 1190 deletions(-) create mode 100644 docs/research/2026-08-28-issue178-cell-shell-culling.md create mode 100644 docs/research/2026-08-28-open-issue-validity-audit.md create mode 100644 docs/research/2026-08-28-owner-closed-issue-validity-audit.md create mode 100644 src/AcDream.App/UI/Layout/CreditsUiController.cs create mode 100644 src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs create mode 100644 src/AcDream.Runtime/Chat/RetailAdministrationCommandDispatcher.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Sky/RainAnimationClockTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CreditsLiveDatTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CreditsUiControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/LayoutImporterInvisibleSweepTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Transport/RetailPacketLossAveragerTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionAdministrationCommandTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Chat/RetailAdministrationCommandDispatcherTests.cs diff --git a/README.md b/README.md index b7e9b5fa..616035ea 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,6 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" -$env:ACDREAM_RETAIL_UI = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj -c Release ``` @@ -208,7 +207,7 @@ built-in policies are `idle`, `lifecycle-smoke`, `observer-movement`, and | `ACDREAM_LIVE=1` | Enable connected mode | | `ACDREAM_TEST_HOST` / `ACDREAM_TEST_PORT` | ACE endpoint | | `ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` | Graphical-client credentials | -| `ACDREAM_RETAIL_UI=1` | Enable the retained retail gameplay UI | +| `ACDREAM_RETAIL_UI=0` | Disable the retained retail gameplay UI for diagnostics; it is enabled by default | | `ACDREAM_DEVTOOLS=1` | Enable ImGui developer tools | | `ACDREAM_NO_AUDIO=1` | Suppress OpenAL initialization | | `ACDREAM_UNCAPPED_RENDER=1` | Disable normal frame pacing for diagnostics | diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 756713a2..7744316e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,10 +24,65 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## 2026-08-28 open-entry validity audit + +The 88 non-final/active-status headings were checked against current source, +tests, recorded evidence, superseding work, and a clean Release build. The +audit flags 35 as confirmed current, 33 as ghosts/not current client issues, +and 20 as needing a focused current-binary verification. It deliberately +changes no issue status and fixes no product code. See +[`docs/research/2026-08-28-open-issue-validity-audit.md`](research/2026-08-28-open-issue-validity-audit.md). + +The exact 57-ID owner-closed batch was audited separately under the same +read-only rule: 3 have unresolved technical evidence but were explicitly +confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to +remain closed. See +[`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md). + +## #453 — Rain and thunder audio disappears while the Rainy sky remains active + +**Status:** DONE — USER-ACCEPTED 2026-08-28 +**Severity:** MEDIUM (weather presentation loses all authored audio) +**Component:** sky default-script playback / audio hook positioning + +**Symptom (owner):** rain remains visible, but its repeating rain sound and +the thunder noises are missing. + +**Root cause:** the installed Rainy carriers are correctly active and their +PES chains contain the expected `SoundTweaked` hooks: wave `0x0A00038B` +repeats every 2.8 seconds for rain, while `0x33000453` schedules the authored +thunder waves. `SkyPesFrameController` refreshed the carrier's visual pose at +the camera every frame but only set `PhysicsScriptRunner`'s sound-dispatch +anchor when the script was first created. After login, teleport, or ordinary +movement displaced the camera, later hooks played at that stale position and +fell outside retail's audible radius. + +**Fix:** refresh the script-owner anchor from the same current camera pose on +every active-carrier update. The script remains persistent—there is no PES +restart or cadence change—but each later sound hook now dispatches from the +viewer-centered sky cell like retail. A regression test starts a persistent +weather carrier, moves the camera 400+ metres before its delayed sound hook, +and pins the hook to the new camera position. + +**Automated gate:** installed-DAT PES audit confirms the exact rain/thunder +waves and authored cadence; the focused sky lifecycle suite passes 8/8. The +canonical Release gate passes 16,322/16,322 across 14 test assemblies with a +0-warning, 0-error build. + +**Acceptance:** during a Rainy group, the repeating rain bed remains audible +after login, movement, and portal travel, and the lightning carrier's authored +thunder sequence is audible without restarting the weather slot. + +**Owner listening gate 2026-08-28:** PASSED — “Ok good working.” #453 is +closed. + +--- + ## #452 — GLFW can dereference another acdream process's private window pointer after cross-process activation -**Status:** IN-PROGRESS — exact root fixed; 100-switch/30-minute dual-client -stress passed 2026-08-27. Graceful exit of both sessions remains before closure. +**Status:** CLOSED 2026-08-28 — exact root fixed; the 100-switch/30-minute +dual-client stress passed 2026-08-27, and both retained `app-release24` +sessions subsequently exited through the graceful character-logoff path. **Component:** graphical host / GLFW Win32 event pump / multi-process stability. **Severity:** HIGH for multi-account play; one of the two sessions is lost without an orderly disconnect. @@ -94,8 +149,12 @@ Evidence: - Windows Application Error events at 2026-08-27 15:01:39, 15:03:35 and 15:08:58 (same module, exception and offset). -**Next:** close only after both `app-release24` sessions exit gracefully; the -activation and sustained in-world portions of the regression gate have passed. +**Closure evidence:** `i451-guard-primary2/stdout.log` records +`logout-confirmed`, return to character select, and orderly plugin teardown; +`i451-guard-secondary/stdout.log` records `graceful logout requested`, +`graceful logout confirmed`, and orderly plugin teardown. Neither process +remains alive. The activation, sustained in-world, and terminal lifecycle +portions of the regression gate all passed. ## #451 — Sanctuary cathedral portal seam leaks exterior world and particles **Status:** DONE — OWNER-ACCEPTED 2026-08-27 ("Looks good! Gate pass now!"). @@ -372,7 +431,9 @@ authoritative 8→8 reconciliation. Owner check is in ## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") -**Status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner acceptance. Reopened after the owner again observed a missing paperdoll that appeared only after waiting. Previously marked FIXED / OWNER-ACCEPTED 2026-08-25. The recurrence exposed two remaining gaps: palette/clothing texture @@ -507,7 +568,7 @@ deleted in `ddbd7e40` per the probe-dies rule; no diagnostic flag remains. ## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load -**Status:** OPEN. +**Status:** FIXED 2026-08-27 by `d123c4b6`; ledger reconciled 2026-08-28. **Component:** rendering tests / zero-allocation pins. **Filed:** 2026-08-25 (surfaced by the Campaign AS AS4 Opus review's full-solution run; unrelated to AS4 — the slice touches no rendering code). @@ -523,9 +584,16 @@ up: the established `Lane=Timing` quarantine per `docs/release-gate.md` (do NOT chase individually), or a warmed re-measure loop like other zero-alloc pins use. Do not weaken the assertion itself without measuring. +**Resolution:** the test now uses the repository's shared +`ZeroAllocationProbe.AssertAllocatesNothing` with a 64-frame measurement +batch after its dense-path warmup. The zero-allocation assertion itself is +unchanged; fixed-cost tiering noise no longer masquerades as per-frame +allocation. + ## #441 — Death return to lifestone: stuck in portal once; arrived once with world not ready (missing doors/portals) -**Status:** OPEN (intermittent — did not reproduce under probes 2026-08-24). +**Status:** CLOSED 2026-08-28 — owner-directed closure after the probed +baseline remained healthy and the symptom did not recur. **Component:** reveal generation / death teleport / entity hydration Owner observed twice (before probes were armed), then could NOT reproduce in a @@ -588,7 +656,14 @@ broadcast (`InfoRegion::OnQualityChanged @ 0x004F0EB0` → ## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load -**Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md) +**Status:** FIXED 2026-08-28 — the end-to-end test now runs in the repository's +serialized `Lane=Timing`, because it deliberately includes the real background +receive owner and its result depends on OS scheduling under parallel load. A +20-run isolated stress pass plus the focused transport suite pass. The +investigation also captured a stale queued NAK overtaken by a newer cumulative +ACK; production now suppresses that already-acknowledged encrypted resend, with +a deterministic regression test. The ordinary equal-watermark NAK still +resends, preserving real loss recovery. **Severity:** LOW (test-infra) **Filed:** 2026-08-24 (one occurrence during Campaign CA CA2's full-suite run) **Component:** Core.Net.Tests / transport lossy decorator @@ -944,7 +1019,12 @@ delete the claim or make it true. Decide which before touching either. ## #433 — Stale entities from OTHER landblocks visible ("hanging in the air") after portal travel or /ls near Holtburg -**Status:** OPEN +**Status:** FIXED 2026-08-28. The retry pump now copies pending receipts into +a retained scratch list instead of allocating `Values.ToArray()` on every +non-empty tick. Scratch is indexed by synchronous call depth so observer +re-entry retains the original snapshot semantics. The warmed one-receipt +fixture fell from 424 B/retry to the event stream's existing 72 B publication +floor; allocation-regression and re-entrant behavior tests pin both results. **Severity:** MEDIUM (visibly wrong world state after ordinary travel) **Filed:** 2026-08-24 (owner report) **Component:** entity lifecycle / landblock retirement / reveal generation @@ -1387,7 +1467,9 @@ transient parallel-load flake observed once, known-flake class). ## #432 — Sustained ~6.3 MB/frame + ~20 ms/frame while Holtburg town center is in view -**Status:** ROOT-CAUSED 2026-08-24 — NOT a production bug; a +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ROOT-CAUSED 2026-08-24 — NOT a production bug; a measurement-harness mode. Fix verified in a hermetic gate, awaiting commit approval. **Reclassified:** the mode is neither town-specific nor view-triggered — it is active in ANY diagnostics-instrumented run, @@ -1501,11 +1583,23 @@ removed; that theory died with `ca4bae77`.) ## #428 — Sky lightning slot fired 3 CreateParticle hooks with no live pose after a TimeSync day-group flip -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** LOW (bounded diagnostic spam; no crash, no leak) **Filed:** 2026-08-23 (launch11.err, first production run of the sky default-script port) **Component:** sky / vfx / script lifecycle +**Resolution.** The captured diagnostic could mean either a missing root or a +missing indexed part; here it was the latter, not a hook that survived owner +teardown. Installed-DAT inspection proves the scripted weather Setups, +including lightning carrier `0x02000BA6`, each have one part with an identity +default frame, while `SkyPesFrameController` published a synthetic root with +an empty part array. The legitimate `CreateParticle part=0` therefore failed +even while the carrier was live. Sky carriers now publish that exact identity +part-0 pose. The regression drives a real part-0 particle hook, proves emitter +creation has no diagnostic, flips to a null day group, and proves the stopped +script cannot fire afterward. The existing invalid-index contract remains +strict; this is not a global fallback from arbitrary missing parts to root. + **Symptom:** three identical stderr lines during the startup TimeSync window: `vfx: No live effect pose for owner 0xF8000007, part 0; emitter 0x320002C2 was not created.` Owner `0xF8000007` = post-scene sky slot index 7 — the Rainy lightning carrier @@ -1569,7 +1663,9 @@ edge lands ~96 % fogged. ## #426 — Every solid-colour (untextured) polygon on every object client-wide was invisible: mesh extraction misread NO_POS_UVS as "no positive face" -**Status:** ✅ FIXED 2026-08-23 (found on the Holtburg windmill axle, GfxObj +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-23 (found on the Holtburg windmill axle, GfxObj 0x010010CE, 30.4N 28.2E). **Component:** content extraction (`MeshExtractor`/`GfxObjMesh`) + draw-time classification (`WbDrawDispatcher`) @@ -1675,7 +1771,9 @@ try again. ## #424 — Client crashed on alt-tab out of exclusive fullscreen: zero-area frame reached `RenderPackActivationExtent.Validate` -**Status:** ✅ FIXED 2026-08-23 (same session it was found — the owner's VM6/VM3 gate launch). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-23 (same session it was found — the owner's VM6/VM3 gate launch). **Component:** rendering / frame orchestration (host minimised-window guard) **Symptom:** exclusive fullscreen 2560×1440, High pack, alt-tab to the desktop → @@ -1783,7 +1881,9 @@ Reference: Campaign AR review F7, ## #420 — Client crashes on the character-select screen (`UiButton.OnDraw` null media-state key) -**Status:** ✅ FIXED 2026-08-19, root cause proven by a reverting test. +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-19, root cause proven by a reverting test. **Symptom:** every launcher-started play session died seconds after login. The user's own session evidence (`%LOCALAPPDATA%\acdream\cache\launcher\ sessions\*/`) shows the exact shape three times in a row on 2026-08-19: @@ -1823,7 +1923,9 @@ died there. Distinct from the LU5 UX work. ## #419 — Portal-tunnel rim polygon visible (FOV-coupled) + ring flash at exit (camera dolly vs retail's view-plane animation) -**Status:** ✅ FIXED / OWNER-ACCEPTED 2026-08-25 — the centered disk, 16:9 +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED / OWNER-ACCEPTED 2026-08-25 — the centered disk, 16:9 faceted outer rim, and destination lower-viewport hole are gone in the live owner-pak gate. **Symptom:** the tunnel's low-poly mouth shows as a faceted polygon @@ -1988,7 +2090,9 @@ owner-pak live capture, and the owner accepted the final in-game transition on ## #418 — Login world load takes ~27 s: publication advances at a flat 32 blocks/s -**Status:** IN-PROGRESS 2026-08-17 — producer half landed (this commit's +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** IN-PROGRESS 2026-08-17 — producer half landed (this commit's striped `LandblockStreamer` worker pool); the pacer measurably remains on the consumer side. **Symptom:** login holds the portal tunnel ~27 s while the 25×25 window (625 landblocks) drips in at exactly 32 blocks/s @@ -2166,7 +2270,9 @@ adding a per-frame diagnostic tax. ## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff -**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same commit as this entry). **Symptom:** log out to character select — the old world's ambient noise continues. **Root cause:** the character-session reset manifest had NO audio step at all. Retail's logoff destroys the @@ -2192,7 +2298,9 @@ logoff plays its cue through the same interface path. ## #416 — Character-select roster hover highlight never clears (sweeping the roster leaves every row highlighted) -**Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same commit as this entry). **Symptom:** hovering a roster row highlights it, but moving off leaves the highlight on — sweep all rows and every one stays lit. **Root cause chain (decomp-grounded):** the roster row template @@ -2225,7 +2333,9 @@ commit/cascade/#408 guard), live-DAT spin pin updated to the commit truth. ## #415 — UI-probe `wait world-*` verbs are dead without `ACDREAM_AUTOMATION_ARTIFACT_DIR` (unbound deferred automation wrapper) -**Status:** ✅ FIXED 2026-08-17 (same round as #414/#416; fix in the same +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-17 (same round as #414/#416; fix in the same commit as this entry's flip). Filed as "reads the reset snapshot" — that diagnosis was WRONG: the completed reveal KEEPS `WorldViewportObserved` (only the next `BeginRevealCore` clears it). The actual cause: @@ -2244,7 +2354,9 @@ failing with a generic timeout. Test apparatus only — no player impact. ## #414 — Mouse cursor disappears at character select after the in-world logoff (teardown fly-mode fallback raw-captures the cursor) -**Status:** ✅ FIXED 2026-08-17 (entry/exit presentation follow-up; fix + +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED 2026-08-17 (entry/exit presentation follow-up; fix + regression tests in the same commit as this entry). **Symptom:** press the indicator bar's X, confirm Yes, land on character select — the OS cursor is gone (and captured). Second Enter still works; the cursor returns once a @@ -2269,12 +2381,13 @@ contract. ## #413 — House tab shows no content (owned-house display, Display* line builders unported) -**Status:** NARROWED 2026-08-17 (House-tab ownership-text closer session); -item 2's not-expired branch closed same-day at the night-round review fix -round (F8); **the houseless case CORRECTED same-day at the morning gate -round (user finding 2 — see the correction note inside item 2)**. Items 1 -and 2 below are DONE; item 3 (owned-house-only builder content) remains -OPEN and is the entire remaining scope. +**Status:** CLOSED 2026-08-28. The complete owned-house builder chain is +ported from the PDB-paired retail binary: purchase/rent payments, bought and +maintenance times, outdoor location, paid/unpaid warning text, purchase-wait +text, and all three authored font-palette states. The previously parsed but +unrouted 0x0227/0x0228 rent-update notices now refresh the same canonical +Runtime owner in both graphical and headless hosts. Synthetic wire/state/UI +coverage passes, and the canonical Release gate passes 16,315/16,315. **What's shipped (this session, on top of Batch C's mount + parser groundwork).** @@ -2355,26 +2468,20 @@ groundwork).** already use (a plain `(layoutId, elementId) -> UiElement` resolve+build, nothing map-specific about it despite the binding's name). -**What remains open — item 3, the entire surviving scope:** +**Final item 3 resolution (2026-08-28):** 3. **The owned-house-only `Display*` line content** (`DisplayBuyPayment`'s OWNED branch — its houseless branch shipped at the 2026-08-17 morning gate correction above — plus `DisplayRentPayment`, `DisplayBuyTime`, `DisplayRentTimes`, `DisplayLocation`, `DisplayWarningText`, all called - from `gmHouseUI::DisplayHouseData @0x004a3380`). Each is dozens-to-a-few-hundred - lines of heavily FPU/string-mangled BN pseudo-C (PStringBase sprintf - chains, `HousePaymentList` iteration, `IsPaidInFull`/ - `ConstructRentWarningMessage`-style formatting) — genuinely sized as its - own session, and only exercisable once a test character actually owns a - house (not true of `+Acdream` today; `RuntimeHouseState.ApplyHouseData` - is wired and tested against a synthetic `GameEvents.HouseData`, but has - never been exercised against a real ACE-owned house). `DisplayLocation` - is the exception: its own logic is clean (`GetHouseLocation` → - `LandDefs::gid_to_lcoord` → the SAME `(v-0x400)*0.1+0.5` transform the - Map tab already ports via `RadarCoordinates`) but its output STRING - format is BN-mangled the same way the Map tab's coordinate readout was — - reuse whatever resolution that gets if/when #413's map coordinate format - string is independently recovered. + from `gmHouseUI::DisplayHouseData @0x004a3380`). Direct x86 disassembly + recovered every hidden literal and arithmetic branch. The Runtime owner + now retains a defensive `HouseData` snapshot, composes payment lists and + pluralization exactly, uses 30-day landscape and 90-day apartment periods, + emits the retail Y-then-X location string, and exposes palette indices + 0/1/2. Rent-time updates clear paid counts; rent-payment updates replace + the list; both rebuild the panel. Apartments omit the location exactly + like retail. **Reference:** `docs/research/2026-08-17-map-house-recon.md` (the recon); `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (this session's owner, @@ -2384,8 +2491,7 @@ AD-107 (the HouseQuery-on-tab-open trigger adaptation); `src/AcDream.Core.Net/Messages/GameEvents.cs` (House parsers), `src/AcDream.Core.Net/GameEventWiring.cs` (delegate holes). -**Acceptance test — CLOSED for the houseless case, LIVE-VERIFIED, still the -target for the owned-house case.** The House tab, on a fresh `+Acdream` +**Acceptance.** The House tab, on a fresh `+Acdream` connect with no owned house, shows "You may buy another house immediately." after the tab is opened (client sends `HouseQuery`, ACE replies `HouseStatus`, `RuntimeHouseState.ApplyHouseStatus` fires, @@ -2404,8 +2510,11 @@ house immediately." and a structural UI dump confirming the House page (`0x100001F7`), its ListBox (`0x100001E6`), and its ONE rendered row (`0x100001E7`, the authored template) all visible and correctly placed. Both launches ended with an ACE-confirmed graceful logout -(`[session] graceful logout confirmed`). Still owed: the owned-house case -once item 3 lands. +(`[session] graceful logout confirmed`). The owned-house path is closed by +exact synthetic snapshots covering every type-dependent branch, both rent- +update wire events, and the real authored palette-index seam. No owned house +exists on the available `+Acdream` test character; a future live visual +spot-check is welcome but is not carrying an unimplemented path. ## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge) @@ -2607,7 +2716,13 @@ centered where retail left-aligns; point-fixed in same point-fix). When this issue's client-wide default sweep runs, fix H and V together. -**Status:** OPEN +**Status:** FIXED 2026-08-28. The shared importer now maps both justification +axes through retail's exact raw table (1=center, 3/5=far edge, all other +values=near edge), defaults unauthored text to Left/Top, and preserves +explicit Center during inheritance by testing raw property presence rather +than using Center as an “unset” sentinel. Focused UI tests pass 250/250, the +automated App lane passes 6506/6506, and the installed-DAT selected corpus +passes 63/63. **Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that relies on the unauthored default, or that authors a raw vertical- justification value other than 1 — currently invisible unless two @@ -2688,7 +2803,9 @@ horizontal `HJustify` mapping while in this code, since it shares the ## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) -**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11, the LIVE-FAILURE round, and the hover-feedback completion round (item-cell tooltips + world-object hover tooltip) all landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the hover-feedback completion round's three items are automated-gate-verified (unit + live-DAT) but the user's connected gate for THOSE items specifically is still owed — see that round's own "Live-verify all three" note. +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11, the LIVE-FAILURE round, and the hover-feedback completion round (item-cell tooltips + world-object hover tooltip) all landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the hover-feedback completion round's three items are automated-gate-verified (unit + live-DAT) but the user's connected gate for THOSE items specifically is still owed — see that round's own "Live-verify all three" note. **Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix) **2026-08-16 re-derivation + port.** Full re-derivation from @@ -3077,9 +3194,9 @@ mistake #306 already named for a different subsystem; (6) its own connected visual gate — hovering a representative sample across multiple screens (chargen, main game UI, chat, Options) side-by-side with retail. -## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide) +## #408 — FIXED: General importer-wide honor of dat property 0x3B (Invisible) -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact) Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16): @@ -3136,6 +3253,23 @@ post-children state reapply (measured: 10 combat-layout elements went un-hit-testable, breaking the spell-favorite drag tests, before the scoping was added). +**Resolution (2026-08-28):** the shared importer now applies +`Invisible=true` as the initial `Visible=false` state for every built +widget, while preserving `AuthoredInvisible` for diagnostics. The old +chargen-only visibility walk and the media-child carve-out's duplicate +write are gone. `UiDatElement`, `UiText`, and `UiButton` also apply the +committed state's own `0x3B` value, including DirectState fallback, matching +the same widget-agnostic `UIElement::OnSetAttribute` switch retail uses. +Controllers can still explicitly show an authored-hidden window later, as +retail does; the combat pointer fixtures now model that real mount edge. + +The permanent installed-DAT sweep enumerates the current resolved corpus as +**1,019 authored-invisible elements across 38 layouts** (the earlier 1,083 +count was from the prior installed DAT snapshot). Of those, 990 survive +behavioral-widget child consumption as widgets and **all 990 start hidden**. +Landmarks cover chargen, combat, and chat. The complete automated App lane +passes **6,511/6,511** with the importer-wide behavior enabled. + ## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) **Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) @@ -3262,22 +3396,19 @@ launch: `started → connected → characterList`, graceful close. ## #404 — ChargenSkillScoreResolver duplicates ChargenTableReader's own SkillTable read -**Status:** OPEN (post-CC cleanup follow-up) +**Status:** FIXED 2026-08-28 **Severity:** LOW **Filed:** 2026-08-16 (Campaign CC CC5 re-review residual round, nit 3) **Component:** `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`ChargenSkillScoreResolver` construction, `:670-672`), `src/AcDream.Content/CharGen/ChargenTableReader.cs` (`:41`, `:61`) -`ChargenSkillScoreResolver`'s constructor takes its OWN independent read of +`ChargenSkillScoreResolver`'s constructor took its OWN independent read of the global SkillTable (portal.dat `0x0E000004`) at composition time (`InteractionRetainedUiComposition.cs:670-672`, `d.Dats.Get(0x0E000004u)`), beside `ChargenTableReader`'s own already-established read of the SAME table -(`ChargenTableReader.cs:41` names the id, `:61` reads it) — which discards -the DAT's `SkillFormula` field entirely (`ChargenTableReader.Project` only -projects `TrainedCost`/`SpecializedCost` per skill into -`ChargenSkillCost`, never `SkillBase.Formula`). Two independent reads of +(`ChargenTableReader.cs:41` names the id, `:61` reads it). Two independent reads of the same DAT file are harmless today (both are read-only, one-shot, under the DAT lock) but are a duplicate-source-of-truth smell: if the two readers ever diverge (a caching change, a future write path), nothing enforces they @@ -3299,9 +3430,25 @@ replacement) takes `ChargenOptions`/a projected formula table instead of a raw `SkillTable`; existing `RetailSkillFormulaTests`/`ChargenTableReaderInstalledDatTests` coverage still passes. +**Resolution (2026-08-28):** `ChargenTableReader` had already gained the +required `ChargenSkillDetail` projection during the CC gate closeout, so the +remaining follow-up was narrower than this original report. The retained-UI +composition now constructs `ChargenSkillScoreResolver` directly from +`Runtime.CharacterCreation.Options`; the resolver consumes the projected +formula/attribute ids and no longer imports or reads a raw `SkillTable`. +`RetailSkillFormula` exposes the same unsigned retail arithmetic for the +projected formula shape, without allocating a DAT object. Focused resolver +tests pass 24/24, synthetic chargen projection tests pass 16/16, and the +installed-DAT `ChargenTableReaderInstalledDatTests` pass 9/9. + ## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch -**Status:** OPEN (post-CC consolidation follow-up) +**Status:** FIXED 2026-08-28. `LiveEntityAnimationPresenter` now delegates +legacy frame advance/wrap and no-sequence part interpolation to the shared +`RetailAnimationCyclePlayback` primitive. Presenter-level regression coverage +pins inclusive-span wrap plus lerp output, including the former negative-rate +edge semantics. Core playback tests pass 11/11 and live-presenter tests pass +14/14. **Severity:** LOW **Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5) **Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`, @@ -3334,13 +3481,18 @@ change to any currently-animated NPC. ## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate -**Status:** OPEN (flake, not a regression) +**Status:** FIXED 2026-08-18 by `dfc841b7`; ledger reconciled 2026-08-28. **Severity:** LOW (test-infra noise; no known production defect) **Filed:** 2026-08-15 (Campaign CC slice CC4 review fix round, R2 — noticed while running the full App.Tests suite repeatedly for the F1/R1 FixedCanvasSize arbiter gate) **Component:** `tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs` +The fix replaced the timing-fragile task observation with a named dedicated +thread, explicit start/block/join bounds, captured worker exceptions, and a +`finally`-protected gate release. It still proves the supplied monitor is the +serialization owner; focused revalidation passes. + `Build_UsesTheSuppliedSharedReaderGate` fails intermittently in full-suite runs (observed roughly 2 of 5 runs) but passes reliably when run in isolation (`--filter FullyQualifiedName~Build_UsesTheSuppliedSharedReaderGate`). @@ -3364,7 +3516,12 @@ intermittently failing on this test. ## #401 — RetailUi should default ON (opt-out), not per-path forced -**Status:** OPEN (product-default decision) +**Status:** FIXED 2026-08-28. `RuntimeOptions.Parse` now enables the retained +retail UI by default for every launch path; only the literal value +`ACDREAM_RETAIL_UI=0` opts out. `FromSessionConfig` no longer force-overrides +the parsed value. README and the authoritative launch-options reference now +describe the default-on contract, and parse/session-config regression tests +pin both the default and explicit opt-out. **Severity:** MEDIUM (recurrence risk) **Filed:** 2026-08-15 (Campaign LA gate-round-2 batch review, F2) **Component:** `src/AcDream.App/RuntimeOptions.cs` @@ -3390,7 +3547,8 @@ opted out; the forcing is gone; docs updated. ## #400 — Character select: Credits button is ghosted; retail opens gmCreditsUI -**Status:** OPEN (post-LA polish) +**Status:** FIXED 2026-08-28 — the authored credits screen and retail scroll/ +return state machine are mounted from the installed DATs. **Severity:** LOW **Filed:** 2026-08-15 (Campaign LA gate round 2, char-select findings batch) **Component:** `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` @@ -3398,14 +3556,25 @@ opted out; the forcing is gone; docs updated. Retail's character-management screen routes the Credits button (`0x100003A3`, listbox-base offset 6 in `gmCharacterManagementUI::ListenToElementMessage @0x004ed5a0`) to -`QueueUIMode(0x10000005)` → `gmCreditsUI` (`Register @0x0047a69e`) — a -scrolling credits screen. acdream ghosts the button (visible, disabled, -no invented action — the same treatment as Create Character). Porting -`gmCreditsUI` is its own small screen (authored layout, scroll behavior, -return-to-select) and is deliberately out of Campaign LA's scope. +`QueueUIMode(0x10000005)` → `gmCreditsUI` (`Register @0x004e7500`) — a +scrolling credits screen. -**Acceptance:** Credits opens the ported retail credits screen and -returns to character select; button re-enabled. +**Fix:** `CreditsUiController` ports +`gmCreditsUI @0x004e6e70..0x004e79e0`: selected roots `0x10000413` and +`0x10000410` from layout enum `0x10000004`, the localized +`ID_Credits1..N` glyph block, cyclic property-`0x10000005` picture strip, +shared pixel scroll, retail duration formula, completion/input action, Please +Wait dialog, and return to character management. Character management now +enables button `0x100003A3` and suppresses its retained presentation while the +credits mode owns the same authored 800x600 canvas. The installed EoR DAT gate +pins layout `0x21000003`, 2,345 credit strings, all seven picture DIDs, the +20-second section value, and the real Type-12 text child. The focused +controller/character tests pass 25/25 (including installed DAT), and the +standard App Release lane passes 6,519/6,519. + +**Acceptance:** automated behavior and installed-DAT acceptance pass. A visual +look is welcome but no longer hides a missing implementation or leaves the +button ghosted. ## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate @@ -3508,9 +3677,9 @@ ever fails, this sink needs the status-stream's credential scanning. ## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host -**Status:** IN-PROGRESS — the isolated process-group implementation and real -Windows fixtures are complete; the LA11 connected acceptance row remains -required before closure. +**Status:** CLOSED 2026-08-28 — the isolated process-group implementation, +real Windows fixtures, and connected Windows supervisor acceptance gate all +pass. **Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE account session stuck for several minutes — a documented project landmine; see CLAUDE.md "Logout-before-reconnect") @@ -3538,17 +3707,26 @@ and a sibling process group that remains running until it receives its own targeted break. Safe-handle cleanup, early-failure termination, and the Linux SIGINT gate remain covered by the Launcher.Core suite. -**Acceptance for closing this issue:** automated process-group and targeted- -signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live -connected row proves `AcDream.Headless` exits gracefully and ACE clears the -session immediately (not after the ~3-minute stale-session window) when -stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the -Linux SIGINT behavior. +**Connected closure evidence (2026-08-28):** the Release headless host entered +the world as `+Acdream`, was stopped through the real Windows +`LauncherProcessSupervisor.Stop` path, received ACE's graceful-logout +confirmation, and emitted `disconnected` plus `exited{code:0,reason:graceful}`. +After the headless host's documented 2.5-second ACE account-release quiescence, +a fresh separately supervised process received the character list, entered the +same character, and repeated the same graceful code-0 shutdown. This is the +intended immediate-reconnect contract; it avoids the former ~3-minute stale +session without pretending ACE releases its account index synchronously with +the wire confirmation. Retained evidence: +`artifacts/live-gates/issue-397-20260828/quiesced-first-status.jsonl` and +`quiesced-second-status.jsonl`. ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click -**Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the -dialog itself. The follow-up crash (`2a81e813`: the wait root's retail class +**Status:** CLOSED 2026-08-28 — the capture instruction dialog is mounted and +the mapping-button path was live-verified crash-free; the old visual re-check +label does not leave a known defect open. + +The follow-up crash (`2a81e813`: the wait root's retail class type 0x19 was unmapped in DatWidgetFactory, so the first mapping-button click threw out of OnClick and killed the client) was live-verified fixed 2026-08-14 — the user exercised the mapping-button path, no crash. @@ -3571,7 +3749,9 @@ cancel would race it). ## #395 — Configure Keyboard: key captions show raw enum spellings, not retail's localized key names -**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-14 at the OP8 re-gate (user report: acdream shows "Shift+ShiftLeft" where retail shows "SKIFT" on their Swedish layout). `DescribeChord` printed Silk enum spellings; retail's @@ -3589,7 +3769,9 @@ DirectInput-vs-GetKeyNameText adaptation and the non-Windows fallback). ## #394 — Configure Keyboard: row captions render in the debug bitmap font, not the authored 18px serif -**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-14 at the OP8 re-gate (user side-by-side screenshot: acdream's "Move Forward" label vs retail's serif). The controller-synthesized row caption (`BuildActionRow`'s composed `UiText`) never set `DatFont`, so it @@ -3605,39 +3787,19 @@ and applied to the caption `UiText`. Probe evidence: `KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings` (env-gated, kept). -## #393 — Texture detail options: retail's "High Resolution Textures" toggle + Landscape/Environment TextureDetail mip-skip +## #393 — Texture detail options / high-resolution DAT handling -**Status:** OPEN — filed 2026-08-14 from the highres-texture verification -(post-M4 nice-to-have; perf/nostalgia option, no gameplay impact). -acdream today loads `client_highres.dat` unconditionally and always picks -`Textures[0]` — retail's MAXIMUM texture detail, verified end-to-end (live -path AND the baked `acdream.pak`; the 2026-08-14 investigation's evidence -chain). Retail additionally offers two knobs acdream has no equivalent for: - -1. **"High Resolution Textures" toggle** (change-notice string - `ID_Option_HighResChange`; `CLCache::LoadHighResDat @0x004FA250` only - runs when armed). Off = `client_highres.dat` never loads; lookups use - the portal-resident versions. acdream shape: a Config-tab option that - skips the highres fallback in `DatCollectionAdapter.TryGet` (portal - class: `_portal || _highRes`, `DatCollectionAdapter.cs:90`) and - `MeshExtractor`'s explicit highres fallbacks (`MeshExtractor.cs:399,767` - — which currently THROW on a miss; the off-path must degrade the way - retail does, NOT throw). **Research prerequisite:** what retail falls - back to for surfaces whose ONLY copy lives in highres. -2. **Texture detail levels** (`Render_LandscapeTextureDetail` / - `Render_EnvironmentTextureDetail` UIPreferences; the pick - `@0x0044C3C8`): the enum is an INDEX into the texture's source-level - (mip) chain — detail 0 keeps every level, detail N drops the N largest - (`i_1 = m_num - esi_1` keeps levels from index N; - `RenderTexture::ShouldDropHighDetail` can force it under memory - pressure). acdream shape: skip/downsample the top N levels at Vulkan - texture upload — works for live uploads and pak payloads alike (the - downsample is at upload, not bake; NO re-bake needed). - -Also carried from the same investigation: a one-off enumeration proving -portal/highres id sets are disjoint (the "portal wins on overlap" -`TryResolvePreferred` corner — no overlap evidence exists, a ~20-line -tool run closes it). +**Status:** VOID/CLOSED — 2026-08-28 by owner direction. The useful behavior +is already implemented: acdream loads and uses `client_highres.dat` through +both the live DAT path and the baked `acdream.pak` path. The filed claim that +retail exposes a separate "High Resolution Textures" toggle was a research +misread: `ID_Option_HighResChange` is a change-notice dialog associated with +the old Landscape/Environment Texture Detail controls, while +`CLCache::LoadHighResDat @0x004FA250` is product-capability loading, not that +checkbox. The remaining retail behavior deliberately discards the largest +source texture levels to lower quality. The project owner explicitly does not +want that legacy degradation added. No renderer change remains from the +abandoned implementation attempt. ## #392 — A refused/failed fullscreen enter leaves `fullscreen: true` persisted against a windowed client @@ -3843,30 +4005,42 @@ should shrink AND the image should re-render crisp at the new pixel count (UI elements occupy proportionally more of the window, retail-style), not scale down blurrily; same for a window-edge drag. -## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge +## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail sizes the popup to content -**Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe -(`OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`, -menuprobe3) measured the vendor category popup's authored ListBox -(`0x21000043/0x10000350`) as edge-docked on all four sides (L=T=R=B=1) — -the exact authored condition that arms retail's -`UIElement_Menu::RecalculatePopupSize @0x0046caf0` size-to-content path -(popup grows/shrinks to the summed row heights, uncapped). Our vendor -dropdown ships G5's fixed 6-row scrollable window instead, which the G5 -retail screenshot ("~one-column-with-scrollbar look") appeared to support -and the vendor connected gate user-passed. The two pieces of evidence -conflict: the decomp mechanism says an 18-category popup should open -full-height (~324 px) with an inert stretched scrollbar strip; the G5 -screenshot was read as a 6-row scroll window. Next step is a retail -side-by-side of the vendor category dropdown specifically (open the -category menu at a vendor with many categories). If retail shows the -full-height popup, flip `UiMenu.PopupSizeToContent = true` in -`VendorUiController` (one line — the mechanism shipped with #385) and -retire the divergence; if retail truly shows a 6-row window, document WHY -the docked ListBox does not trigger RecalculatePopupSize there (a message -routing difference is plausible: the vendor popup's items are inserted -BEFORE `RegisterForElementMessages`, so the 0x32 broadcast may never reach -the menu). Register row AD-88 (unclear) tracks it. +**Status:** DONE 2026-08-28. The named-retail path resolves the earlier +screenshot ambiguity: `VendorItemsUI::OpenVendor @0x004c16d0` inserts only +categories actually present; `UIElement_ListBox::UpdateLayout @0x0046e460` +sums those row heights; `UIElement_Scrollable::ResizeScrollableArea +@0x00474730` broadcasts message `0x32`; and +`UIElement_Menu::ListenToElementMessage` answers it with +`RecalculatePopupSize @0x0046caf0`. Because ListBox +`0x21000043/0x10000350` is docked on all four edges, the popup shrinks or +grows to the complete category list, uncapped. The old registration-timing +hypothesis was false: `MakePopup` registers the popup message route during +menu initialization, before a vendor can be opened. + +Installed-DAT inspection also found effective scrollbar property +`0x79=true` on sibling `0x10000351`. That is retail's +`HideWhenDisabled`: once size-to-content makes viewport equal content, the +scrollbar disappears completely rather than leaving the striped disabled +track seen in the report. `VendorUiController` now enables both +`UiMenu.PopupSizeToContent` and +`UiMenu.PopupScrollbarHideWhenDisabled`; drawing and pointer dispatch share +the hidden-presentation projection. Focused menu/vendor coverage pins short +content, content beyond the old six-row extent, hidden disabled chrome/input, +and the still-visible overflow case. AD-88 is retired. + +**Same-day visual re-gate correction:** the first implementation hid the +scrollbar sprites and input but `UiMenu.InteriorW` still unconditionally +added the authored sibling's 16-pixel width, leaving the empty black +placeholder reported in the second screenshot. Retail owns a real sibling +widget and `UIElement_Scrollbar::UpdateLayout @0x004710d0` applies +`SetVisible(false)` when Disabled + HideDisabled are both set; acdream +procedurally flattens that sibling into `UiMenu`, so the equivalent visible +result requires its effective width to become zero too. The shared +presentation predicate now controls drawing, input, and popup width. Tests +pin 110 px outer width for the hidden short-list case (100 px row + two 5 px +bevels) and 126 px when a real 16 px scrollbar is visible. ## #385 — Options-panel dropdowns: gold left-aligned text + fixed 6-row popup (retail: white, centered, size-to-content) @@ -3891,8 +4065,11 @@ is #376/#377) — no change. ## #384 — FA6 allegiance-swear bot gate: ACE returns no response to 0x001D swear (no confirmation/0x0020/error) -**Status:** OPEN — filed 2026-08-12 at Campaign FA slice FA6. The two-bot -headless fellowship/allegiance connected gate +**Status:** CLOSED AS EXTERNAL ACE BLOCKER 2026-08-28 — the client sends the +swear action; the missing response is server behavior. + +Filed 2026-08-12 at Campaign FA slice FA6. The two-bot headless +fellowship/allegiance connected gate (`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs`, `FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) ran live against local ACE (`127.0.0.1:9000`, `testaccount`/`+Acdream` as @@ -3964,7 +4141,10 @@ the flag is off. ## #383 — Installed-DAT vs committed-fixture drift: regeneration produces large diffs in existing UI fixtures -**Status:** OPEN — filed 2026-08-12 at Campaign FA slice FA3. Running the +**Status:** CLOSED AS FIXTURE MAINTENANCE 2026-08-28 — installed-DAT provenance +drift is an environment/fixture concern, not a client product defect. + +Filed 2026-08-12 at Campaign FA slice FA3. Running the env-gated fixture generator (`ACDREAM_REGENERATE_UI_FIXTURES=1`) on this machine to dump the NEW social-panel fixture also silently rewrote `keyboard_config_21000009.json` and `options_2100002B.json` with LARGE @@ -4230,7 +4410,11 @@ cap and open a real bordered, scrollable popup on click.** ## #377 — Startup CRASH (0xC0000005 in Glfw.GetVideoMode) when settings.json has `fullscreen: true` -**Status:** OPEN, NOT REPRODUCIBLE on current code (2026-08-13, display +**Status:** CLOSED 2026-08-28 — superseded by #388's state-aware native mode +switch and topology guard. The current binary already completed three +consecutive persisted-fullscreen cold starts, and the later physical-display +gate completed the exact mode-switch path with graceful desktop restoration. +Historical status: NOT REPRODUCIBLE on current code (2026-08-13, display block slice 4 attempt). Three consecutive `fullscreen: true` launches on the exact current binary (post-#387/#389/#391) all reached in-world cleanly at the 2560x1440 desktop mode with the swapchain following @@ -4535,7 +4719,16 @@ symptom. ## #360 — @allegiance/@house management dispatchers only port their simple subcommands -**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4; corrected +**Status:** CLOSED 2026-08-28. The complete retail command grammar now runs +through one presentation-independent dispatcher shared by the graphical and +headless hosts. Every allegiance, house, and standalone `@motd` branch has its +named-retail argument/refusal behavior and its byte-verified ACE GameAction +packet; unknown nested commands remain client-local. Exact grammar, packet, +sequence, router-lifetime, and both-host regression matrices pass. The +canonical Release gate passes **16,309/16,309** tests with zero failures. +Register row TS-68 is retired. + +**Historical filing:** filed 2026-08-09, Campaign CH slice CH4; corrected 2026-08-09 at the CH4 REJECT-review (Blocker 1). Retail's `@allegiance`/`@all` and `@house`/`@hou` are 12- and 15-subcommand local command dispatchers (`ClientCommunicationSystem::DoAllegiance @ @@ -4589,16 +4782,19 @@ slice CH4). ## #361 — @day / @log / @render pure-local commands recognized in help only, not executed -**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Three -retail-registered pure-local verbs are not yet wired to real behavior: -`@day` (daylight override — needs a sky/time-of-day hook the renderer -doesn't expose), `@log` (chat-to-file logging — deferred to avoid an -unaudited file-handle lifecycle across session reconnects; see AP/TS-69 -for the reasoning), and `@render` (retail's `SmartBox::HandleRenderOption` -— acdream has no equivalent render-option surface). All three are -recognized by `/help ` (`RetailCommandHelpTable`) with retail's own -extracted help text, but fall through to server passthrough on execution. -Register row: TS-69. +**Status:** CLOSED 2026-08-28. All three commands now execute locally. +`@log` owns a reconnect-safe chat-log lifecycle. `@day` toggles the canonical +`PersistentAtDay` character option, reproduces retail's two exact replies, +and samples noon landscape lighting without freezing the live sky/fog clock. +`@render radius` accepts retail's 5–25 range and updates the same persisted +landscape-radius preference used by the Config panel; that panel now carries +retail's real `{3,5,8,11,15,25}` payloads. `@render fov` accepts retail's +10–160 range and updates the persisted field of view. Usage, range errors, +`atoi`-style numeric-prefix parsing, ignored extra arguments, and silent +unknown options match the named retail handlers. The graphical command, +render-frame, settings, Config-panel, and router regressions pass; standard +App tests pass 6,542/6,542, UI Abstractions pass 884/884, and the complete +Release solution builds with zero warnings/errors. Register row: TS-69. **Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, slice CH4). @@ -4850,7 +5046,10 @@ default case against a real DAT-loaded landblock). ## #370 — Headless jump-probe: the released jump never registers as airborne (proven NOT a threading artifact) -**Status:** OPEN — filed 2026-08-10 during #368's fix verification. +**Status:** CLOSED 2026-08-28 — the current published headless-player path was +reproduced in-process through the real headless command source and frame host: +charged `SetIntent` release becomes airborne with positive vertical velocity. +The historical timeout is stale after the later movement/physics cutovers. **Symptom:** with #368 fixed (one dedicated update thread, thread migration provably gone — the new affinity test pins it), the `jump-probe` @@ -4884,8 +5083,11 @@ the timeout fires seconds after `releasing jump (fire)`. ## #369 — Unconfirmed whether retail's floating chat windows share the main window's currently-selected talk-focus channel -**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6b (register row -AP-188). The floating chat window LayoutDesc (`0x2100005B`) authors no +**Status:** CLOSED AS RESEARCH 2026-08-28 — this records an unanswered retail +question, not an observed client mismatch. + +Filed 2026-08-10, Campaign CH slice CH6b (register row AP-188). The floating +chat window LayoutDesc (`0x2100005B`) authors no talk-focus menu (`docs/research/2026-08-09-chat-retail-window-shell.md` §2.2 — only the main window's `0x2100006F` has one, element `0x10000014`), so acdream's `FloatingChatWindowController` hardcodes every floaty window's @@ -4915,9 +5117,12 @@ controllers read instead of the main window's private field. ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired -**Status:** OPEN, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C -Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is -now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField` +**Status:** CLOSED 2026-08-28 — `ChatWindowController` binds the indicator, +tracks unseen text, advances its authored attention sequence, and clears it on +click; focused regression coverage is present. + +Campaign CC gate round 1 Batch C Commit 2 completed the build half. +`LayoutImporter.BuildWidget` gained a `UiText`/`UiField` media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay carve-out, EXACTLY the shape this issue proposed) as part of a chargen description-box fix; the client-wide blast-radius sweep that fix's own @@ -4925,10 +5130,8 @@ tests run (`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`) independently re-confirmed `0x1000048C` under `0x10000011` in layout `0x2100006F` as one of the affected elements — it now builds as a real -widget instead of being silently swallowed. **Still open:** no controller -binds or drives its visible state (STILL the original ask — what triggers -retail's "new text" indicator, and what it does on click, remains -un-researched); this issue stays open for that behavioral half. +widget instead of being silently swallowed. The later controller work completed +the behavioral half described above. **Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior, still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs` @@ -5089,8 +5292,11 @@ this session's hard constraints excluded client launches. ## #359 — 0x019E PlayerKilled line prints to participants — retail suppresses it -**Status:** OPEN — filed 2026-08-09 at the CH1 Opus review. Pre-existing (not -introduced by CH1); candidate for CH4/CH5. +**Status:** FIXED 2026-08-28. The live social route now supplies the canonical +local-player GUID to `ChatLog.OnPlayerKilled`; the latter suppresses the +0x019E line for both victim and killer while preserving it for an uninvolved +bystander, matching `ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320`. +Core and live-router regression tests cover all three recipients. **Symptom:** `ChatLog.OnPlayerKilled` (`src/AcDream.Core/Chat/ChatLog.cs`) always appends the death message for every recipient of the `0x019E` @@ -5334,7 +5540,10 @@ wrap threshold is the authored PIXEL width, not a character count. ## #352 — Vendor range-watcher cylinder metric: discriminating unit test deferred -**Status:** OPEN (filed 2026-08-08). The EnforceRange cylinder-gap fix +**Status:** CLOSED AS TEST DEBT 2026-08-28 — the behavior passed its live gate; +the remaining request is optional discriminating coverage, not a vendor defect. + +The EnforceRange cylinder-gap fix (acceptance-band self-close, vendor-verify-gate.log evidence) landed with the existing 17-range/lifecycle tests green but WITHOUT a unit test that discriminates cylinder-vs-center (needs an IPhysicsObjHost fake — 38 @@ -5366,7 +5575,8 @@ configs. ## #350 — Render-shadow ledger overflow after 2h42m: lifetime int counters in a never-reset accumulator -**Status:** FIXED IN TREE 2026-08-08 (pending clean-room + landing). +**Status:** CLOSED 2026-08-28 — the production lifetime counters are `long`; +the old pending-landing label is stale. **Evidence:** `vendor-buy-gate.log` ~1606 — checked OverflowException in `RenderSceneShadowRuntime.Add` from `UpdateFrameOrchestrator.Tick`, exit 82, 2h42m into a SINGLE world generation (login 20:28 -> crash 23:10, one @@ -5392,7 +5602,9 @@ legitimately unbounded lifetime telemetry that was simply undersized. ## #348 — Render-loop death by Win32 cursor-handle exhaustion: Silk recreates the native cursor on every alternation -**Status:** FIX IN TREE (2026-08-08) pending the vendor-gate relaunch. +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIX IN TREE (2026-08-08) pending the vendor-gate relaunch. **Evidence:** `vendor-gate.log` — `Silk.NET.GLFW.GlfwException: PlatformError: Win32: Failed to create cursor: Not enough memory` thrown from `RetailCursorManager.ApplyGlobal` inside `RenderFrameOrchestrator.Render`, @@ -5764,9 +5976,10 @@ reasoned-from-source diagnoses. ## #346 — `PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays` is a SIXTH load-sensitive flake -**Status:** OPEN. LOW. Allocation-count assertion, passed in isolation and on -two subsequent full runs. Same FILE as #302 but a DIFFERENT test — filed -separately per the never-conflate rule. +**Status:** FIXED 2026-08-18 by `dfc841b7`; ledger reconciled 2026-08-28. LOW. +The test now crosses tiered-JIT thresholds before measurement and accepts the +best of five warmed batches while retaining a threshold hundreds of times +below the former per-call allocation regression. **Filed:** 2026-08-08, observed during #344's suite runs. **2026-08-16 recurrence (Campaign #409 tooltip review-fix round).** The Opus @@ -5860,8 +6073,9 @@ two AD-66 skips retired).** AD-66's register row is retired; the historical flip stands recorded as unexplained-but-unreproducible. AD-69 remains the one follow-up in that block. Pending: the user's hover-look slope gate. -**Status:** OPEN — HIGH priority for the next physics session; the fix itself -is byte-proven, the BLOCKER is that the measurement chain contradicted itself. +**Status:** CLOSED 2026-08-28 — the relanded fix passed its ten-run, +bit-identical gate; the contradictory historical measurement is retained only +as evidence. **Filed:** 2026-08-07 (overnight), at the S4 landing split. Retail's `adjust_offset` safety push-out uses the BARE sphere radius in both @@ -6034,26 +6248,31 @@ stays ACTIVE. **The byte evidence was never the open question — do not ## #342 — `Issue265SteepSlopeCaptureBisectTests.cs:920` is a tautology: `Assert.Equal(x.Z > 0.01f, x.Z > 0.01f)` -**Status:** OPEN. LOW — a dead assertion that can never fail, in the -steep-slope family Campaign S leans on. Found by the S4 review (F8), -out of that slice's scope. Fix = recover the intended comparison from the -test's context, not just delete. +**Status:** CLOSED 2026-08-28 — the current assertion compares the old-model +and new-model values rather than the same expression to itself. **Filed:** 2026-08-07. --- ## #340 — `StreamingWorkBudgetTests.DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget` is a FIFTH load-sensitive flake -**Status:** OPEN. LOW. +**Status:** FIXED 2026-08-18 by `dfc841b7`; ledger reconciled 2026-08-28. LOW. **Filed:** 2026-08-07 (overnight), first observed in a clean-room full-suite run; passes standalone immediately after. Distinct from #302, #308, #321 and #336 per the never-conflate rule. Same class: load-sensitive, deterministic in isolation. +**Resolution:** `StreamingController` gained an injected meter clock and this +fixture now uses a constant timestamp. Scheduler policy is fully deterministic +under suite load instead of racing the production `Stopwatch`. + --- ## #339 — Stuck in portal space / at login: the reveal never becomes ready — MECHANISM CAUGHT 2026-08-07 evening, full stack +**Status:** CLOSED 2026-08-28 — fixed and live-validated 2026-08-07; the +original filing below is retained as historical evidence. + **BREAK: the crash is caught.** The Session-B gate launch reproduced the hang at LOGIN (cell 0xA8B4002F, all readiness flags False forever) and this time the log carries an unhandled `System.OverflowException` with a full stack: @@ -6117,8 +6336,7 @@ separately below. ## #339 (original) — Stuck in portal space: the destination reveal generation never becomes ready -**Status:** OPEN — observed live 2026-08-07, evidence captured. **Not chased**; -user directed it be fixed later. +**Historical status:** OPEN — observed live 2026-08-07, evidence captured. **Severity:** HIGH when it fires — the session is unrecoverable without closing the client. The player never leaves portal space. **Component:** streaming / world reveal (NOT physics — see below). @@ -6310,7 +6528,9 @@ different mechanisms and need separate gates. ## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance -**Status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIXED 2026-08-06 by #333's fix — the query-site broadphase reach filter is **deleted**, because retail has none. Awaiting the user's live acceptance at the Neftet plateau; the offline gate is `Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover`, @@ -6464,7 +6684,9 @@ byte-settled; the one-ULP WhichSide tie) live in the narrowed AP-159 row. ## #333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested -**Status:** FIXED 2026-08-06 — **the filter is deleted, not re-centred.** +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIXED 2026-08-06 — **the filter is deleted, not re-centred.** Re-centring it would have kept an invention retail does not have; the disassembly below establishes that retail walks the cell's shadow list unconditionally. Cell membership IS retail's broad phase, and the BSP walk's @@ -6865,7 +7087,7 @@ because the intent is to fix it. ## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** MEDIUM (no observed symptom; reachability against ACE is unmeasured — see below. The behaviour when reached is four simultaneous divergences, not one.) @@ -6941,6 +7163,14 @@ covered by a discriminating test at the disposition boundary AND at the classifier's authority validation; the three consumers in item 2 checked against a stale-but-equal teleport pair; AP-148 retired in the same commit. +**Resolution:** Gate A now uses retail's wrap-safe not-older predicate, so an +equal or newer wire TELEPORT_TS takes `ForcePosition` without consuming the +stored teleport channel. Runtime authority accepts a local non-regressed +force pair while still rejecting remote and regressed authority. Core tests +cover ordinary and wrap-boundary newer stamps; merge/route/drive tests prove +the accepted pair remains stale-but-equal, velocity is preserved, no teleport +hook is armed, and the immediate position acknowledgement still occurs. + ## #324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes **Status:** OPEN @@ -7005,12 +7235,23 @@ the same commit; the eight D1 sabotages still discriminate. ## #320 — The local player's canonical cell does not track ordinary movement (follow-up from #319) -**Status:** OPEN -**Severity:** LOW today (no observed symptom — see below); the correctness -question is real and unresolved +**Status:** FIXED 2026-08-28 (the Runtime physics cutover had already retired +the defect; this audit added the missing local-player retirement proof) +**Severity:** CLOSED **Filed:** 2026-08-05, filed in the #319 fix commit per that contract's §2.2/§4/§9 **Component:** physics / entity lifetime / local player canonical cell +**Resolution.** The premise became stale at the per-session Runtime physics +cutover (`7e6033d0`). `RuntimeOrdinaryPhysicsUpdater.Complete` commits the +transition resolver's exact `FullCellId` through +`RuntimePhysicsState.CommitOrdinaryCell`; `CellCommitted` then synchronously +rebuckets the App projection. This is the same canonical transition family as +retail's per-frame `SetPositionInternal`, and it is suppressed while portal +space owns placement. The 2026-08-28 audit changed the existing transition +test into an exact local-player fixture and proved that retiring the source +landblock after the player walks across the boundary does not select or park +the player. No production change was required. + **Description.** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: @@ -7119,7 +7360,8 @@ verdict. ## #318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering -**Status:** OPEN +**Status:** CLOSED AS TEST DEBT 2026-08-28 — no current product defect is +established; any desired extra composition coverage belongs in the test plan. **Severity:** LOW (does not block round-3 acceptance per both reviewers; carried into C5) **Filed:** 2026-08-05, C4 route 3 round-3 review (retail B5/A5, architecture @@ -7178,7 +7420,13 @@ shadow registry specifically. ## #317 — `TryCommitAuthoritativeVelocity`'s call site has no established retail basis -**Status:** OPEN +**Status:** FIXED 2026-08-28 — the full accepted-Position chain confirms the +PositionPack vector is only passed into `CPhysicsObj::MoveOrTeleport`, which +never reads it; neither `UnpackPositionEvent` nor the remote branch calls +`set_velocity`. The acdream-only body write was removed. Position velocity is +still retained for server-controlled animation/dead reckoning, while the +separate VectorUpdate route remains the authoritative `set_velocity` path. +The 28-case OnPosition collapse matrix includes a non-overwrite regression. **Severity:** LOW (tracking only; no known observable defect) **Filed:** 2026-08-04, C4 route 5 (projectile authoritative placement) round-2 review, MINOR (c) @@ -7229,7 +7477,7 @@ carried over from the route 4b-3 round-2 reviews. Evidence: ## #313 — `DeclareValid`'s `SetSelectedObject` split-recovery is not ported -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** LOW (selection UX, not placement) **Filed:** 2026-08-04 **Component:** UI / inventory / selection @@ -7267,6 +7515,12 @@ recovery window. Out of C4 scope — do not implement as part of a placement- focused change; this is selection UX and mixing it into a placement closure makes the landing un-reviewable (per the route 6 contract). +**Resolution:** `InventoryWorldDropProjectionController` now borrows the +session's canonical `SelectionState` and transfers selection to the recovered +split-result GUID immediately after successful hydration. Tests cover the +matching partial-stack result, the ten-second recovery behavior, and prove a +second unrelated unknown GUID cannot steal selection. + ## #314 — Split recovery throws instead of recovering when the source's retained Movement/ServerControlledMove timestamps are nonzero **Status:** CLOSED 2026-08-04 by `daef7c98` — `BuildSpawn`'s `Timestamps` @@ -7395,7 +7649,7 @@ the delta-review round on the same route's remediation. Evidence: ## #309 — Cancelled lost-cell park re-shows the entity where retail would keep it hidden -**Status:** **DEFERRED as an ACCEPTED DIVERGENCE — user decision 2026-08-06.** +**Status:** CLOSED AS ACCEPTED DIVERGENCE 2026-08-28 — user decision 2026-08-06. The standing record is register row **AP-136**, which already carries the retail mechanism, the exact divergence, and the observable; this issue is no longer a planned fix and does not block the placement-cutover campaign or C5c. @@ -7550,7 +7804,7 @@ Steps 1-5 are the user-visible acceptance for AP-136's residual. ## #310 — Retained preparation retry stalls landblock retirement with no bound -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** HIGH **Filed:** 2026-08-04 **Component:** physics / streaming @@ -7586,9 +7840,22 @@ permission is refused before `ParkCollisionResidents` is ever entered. indefinitely — either the deadline is driven in production or the retirement can proceed past stale placement debt. +**Resolution:** Prefix retirement now supersedes an authored mover that is +still waiting for its first preparation. Before ordinary placement-debt +evaluation, Runtime cancels the exact unprepared operation touching the +retiring prefix; the resident then enters the normal withdrawal/acknowledgement +handshake. Prepared, wakeable-lost-cell, and dormant-local operations retain +their existing authority. The production-shaped regression proves the original +token is displaced and retirement converges without an asset-ready edge or a +new inbound packet. + ## #311 — RetryPendingProjections allocates a fresh array on every non-empty call -**Status:** OPEN +**Status:** FIXED 2026-08-28. The retry pump now uses retained call-depth +scratch lists, preserving deterministic snapshot semantics and re-entrant +behavior without allocating a fresh array. The warmed fixture fell from +424 B/retry to the existing 72 B event-publication floor; all 73 focused +runtime tests pass. **Severity:** LOW (perf, not correctness) **Filed:** 2026-08-04 **Component:** physics / headless @@ -8835,7 +9102,8 @@ for a follow-up that did not exist. Both now cite #322. ## #274 — Restricted/barred-house entry needs a connected retail comparison -**Status:** OPEN — explicitly deferred by the user on 2026-07-31 +**Status:** CLOSED 2026-08-28 — this is a deferred comparison/gate request, +not an observed client failure. **Severity:** LOW (validation debt; no confirmed failure) **Filed:** 2026-07-31 **Component:** physics / EnvCell entry restrictions @@ -8854,7 +9122,9 @@ character access state, and result before closing. ## #273 — ACDream can squeeze through tight world gaps that block retail -**Status:** OPEN — live mismatch confirmed 2026-07-31; exact location/capture +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN — live mismatch confirmed 2026-07-31; exact location/capture still required **Severity:** MEDIUM (world traversal differs from retail) **Filed:** 2026-07-31 @@ -9068,8 +9338,9 @@ row refresh after the final retained-UI invalidation correction. ## #267 — Vitae does not update the character panel's skills/attributes display -**Status:** IMPLEMENTED 2026-07-30 (`cf2605fa`, merged) — closure pends -the user visual check. Retail finding: primary attributes are +**Status:** CLOSED 2026-08-28 — Campaign P's final connected user matrix was +accepted 2026-07-31, including the live vitae/buff panel values and immediate +skill-row refresh. Retail finding: primary attributes are VITAE-IMMUNE (`EnchantAttribute` 0x00594570 never references the vitae singleton) — only skills and vitals take the penalty. Panel now shows effective values; skill footer shows the vitae parenthetical (e.g. @@ -9125,8 +9396,9 @@ ACE's >= reading" warning. ## #265 — Steep-slope response set: uphill-jump bounce, roof slides lost, edge wedge (TS-4 removal fallout — REVERTED) -**Status:** IMPLEMENTED 2026-07-30 (landing-bounce rework: retail check_contact seed + SetPositionInternal commit + live 5% elasticity reflect; docs/research/2026-07-30-landing-bounce-family.md) — pending user live gate (downhill bounce chain, flat pop, uphill clean landing) -user's visual-gate acceptance. The named culprit for symptoms (b) and (c) +**Status:** CLOSED 2026-08-28 — Campaign P's final connected user matrix was +accepted 2026-07-31: downhill bounce chain, flat pop, and clean uphill landing. +The named culprit for symptoms (b) and (c) was capture-bisected to a THIRD, pre-existing (frozen-phase, predates Campaign P by ten days) mechanism — neither the S1 nor S2 suspects named below — and is now ported. Symptom (a) is confirmed a SEPARATE, @@ -9222,7 +9494,9 @@ model). ## #263 — Drudge Scrying Orb still occludes its particles after the composite-translucency fix -**Status:** OPEN (deferred by user 2026-07-29) +**Status:** CLOSED 2026-08-28 — owner-confirmed after validity audit. + +**Previous status:** OPEN (deferred by user 2026-07-29) **Severity:** LOW (single known item; "very subtle" per user) **Component:** rendering / world translucency / particles @@ -9271,7 +9545,9 @@ regressing the #225 lifestone/candle compositing. ## #264 — Water semantics: WATER_CONTACT_TS consumer + two unverified swim behaviors -**Status:** OPEN (filed 2026-07-30, Campaign P Slice P4 AP-10 closeout) +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN (filed 2026-07-30, Campaign P Slice P4 AP-10 closeout) **Severity:** LOW (no confirmed divergence; research/verification follow-up) **Component:** physics / terrain / water @@ -9319,7 +9595,8 @@ sub-item resolves. ## #262 — Run-on-the-spot at first login: no displacement until a recall reset -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner reports the first-login movement issue +is solved. **Severity:** MEDIUM (self-heals via any teleport; first-login only so far) **Filed:** 2026-07-29 (Campaign N acceptance run 1 on Coldeve) **Component:** login flow / local movement / physics readiness @@ -9416,7 +9693,10 @@ scenario 11 (20 fresh logins) provides the structured re-test. ## #261 — Wire LinkStatusSnapshot.PacketLossPercentage from retail's formula -**Status:** OPEN +**Status:** FIXED 2026-08-28 — ported retail's 2-second heartbeat and +40-sample ushort windows from `CLinkStatusAverages::AddSnapshot @ 0x00546650`; +`GetAveragePacketLoss @ 0x00546610` now receives live NAK, retransmit, +received, and sent counters. Focused transport/link-status gate: 33/33. **Severity:** LOW **Filed:** 2026-07-29 **Component:** net (link-status presentation) @@ -9519,8 +9799,8 @@ attach→GPU-resource path for create-without-release. Do NOT patch the symptom ## #259 — Win32 Vulkan surface creation fails machine-wide (`ERROR_UNKNOWN`) -**Status:** OPEN — environment fault, not a product defect; recorded so the next -reader does not bisect the tree for it +**Status:** CLOSED 2026-08-28 — confirmed machine/driver environment fault, +not an acdream product defect; retained only as diagnostic history. **Severity:** HIGH while it lasts (the client cannot start at all) **Filed:** 2026-07-29 **Component:** host machine / AMD driver / Win32 WSI @@ -9570,7 +9850,8 @@ self-differential is still available once a window can be created. ## #258 — Developer panels have no host after V11 deleted ImGui -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner classified the deleted developer-panel +host request as void; it is not desired product scope. **Severity:** MEDIUM (developer capability regression; no player-facing effect) **Filed:** 2026-07-29 **Component:** developer tooling / retained UI @@ -9773,7 +10054,8 @@ afterwards. **The test was not modified.** ## #256 — Server-spawned objects go invisible after repeated portal runs -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — superseded by #260; its discriminator found no +missing-object drift and the transport-loss mechanism was fixed elsewhere. **Severity:** HIGH (world objects invisible but interactive; live-server observed) **Filed:** 2026-07-28 **Component:** live-entity render publication / streaming (backend attribution pending) @@ -9850,7 +10132,9 @@ CSVs, difference maps, `churn-soak.json`). ## #257 — Working set balloons to ~1.5 GB over a live portal-churn session -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — the suspected leak was not reproduced by its +own churn discriminator, and the measured rendering/package architecture has +since been replaced. **Severity:** HIGH (memory; live-server observed) **Filed:** 2026-07-28 **Component:** GPU/streaming resource lifetime (backend attribution pending) @@ -9925,7 +10209,9 @@ re-run with walked portal transits rather than `/teleloc`. Artifacts: ## #253 — Attribute/skill icons: not centered in their cells, and fully opaque -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW (visual fidelity; user-observed) **Filed:** 2026-07-28 **Component:** retail UI / character sheet (D.2b) @@ -10030,7 +10316,8 @@ its only `CameraDiagnostics` writer, but four classes there call ## #251 — glClientWaitSync returned 0 and crashed the render loop, once in nine connected runs -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — obsolete OpenGL-path report; the production +renderer is Vulkan-only and no `glClientWaitSync` path remains. **Severity:** MEDIUM (one observed occurrence; kills the process when it fires) **Filed:** 2026-07-28 **Component:** rendering / GL frame-flight fences @@ -10087,7 +10374,9 @@ unexpected status is exactly the signal §5.5 spent three days wishing it had. ## #250 — Zero-allocation tests fail intermittently, roughly 1 run in 3 -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — all four probes now use the shared tiered-JIT/ +OSR-safe `ZeroAllocationProbe`; the former roughly-one-in-three family passed +12/12 current repeated runs under concurrent test-process load. **Severity:** MEDIUM (undermines every "tests green" gate) **Filed:** 2026-07-27 **Component:** tests / allocation assertions @@ -10224,8 +10513,8 @@ the probe removes, so it could likely be tightened to zero on the probe now. ## #249 — Bindless handles stay resident after their table slot is released -**Status:** OPEN -**Severity:** MEDIUM +**Status:** FIXED 2026-08-28 +**Severity:** CLOSED **Filed:** 2026-07-27 **Component:** rendering / GPU resource lifetime @@ -10555,7 +10844,9 @@ then its description after one retail paragraph break, without the bogus ## #235 — Capped/RDP jump presentation aliases the 30 Hz object clock -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-confirmed after validity audit. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-07-23 **Component:** local animation / render interpolation / frame pacing @@ -10750,11 +11041,20 @@ reports decoded bytes, budget, and evictions. ## #241 — InteriorEntityPartition never uses its per-landblock AABBs to cull -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** MEDIUM **Filed:** 2026-07-24 **Component:** render +**Resolution:** every partition overload now accepts the prepared frame +frustum and skips a rejected landblock before touching its entity list. The +production `RetailPViewRenderer` passes the same prepared frustum and exempts +the player's current landblock, matching the existing dispatcher safeguard. +The no-frustum overload remains conservative for one-shot/tests, and a focused +test proves a rejected landblock is omitted while the camera-landblock +exception survives. This matters even after the retained-scene cutover because +the current-render oracle still builds the legacy partition every frame. + **Description:** `Partition` receives per-landblock `AabbMin`/`AabbMax` but walks every near-tier landblock's full entity list every frame without a landblock-level frustum test — while `WorldSceneDiagnosticsController` @@ -10777,7 +11077,8 @@ Slice H-a / G. ## #242 — Static publication rebuilds a third dictionary and re-sorts per completion attempt -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — current publication prepares the replacement +snapshot once and reuses it across completion retries. **Severity:** LOW **Filed:** 2026-07-24 **Component:** streaming @@ -11020,7 +11321,8 @@ from crossing into a new world. ## #228 — Clean Release build emits 17 test-project warnings -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — disproved by a clean current Release build with +zero warnings and zero errors. **Severity:** LOW **Filed:** 2026-07-20 **Component:** tests / build hygiene @@ -11055,7 +11357,9 @@ the full 6,558-pass / 5-skip suite remains green. ## #226 — Retail building/EnvCell detail-texture overlay is not rendered -**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21; re-ported to +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21; re-ported to the single-pass path 2026-08-22 by Campaign VM VM1 after VM2's live cdb read found retail hardware never takes the two-pass fallback this issue originally ported (see @@ -11129,7 +11433,9 @@ during B and restored true, and logout was ACE-confirmed graceful. ## #225 — Scene particles overpaint translucent world objects -**Status:** IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending **Severity:** MEDIUM **Filed:** 2026-07-18 **Component:** rendering / world translucency / particles @@ -11684,7 +11990,8 @@ right-aligned Total Experience/progress, and Unassigned Experience all updated. ## #213 — Retail client commands were sent to ACE as chat text -**Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate +**Status:** CLOSED 2026-08-28 — retail commands route through the dedicated +client-command controller; the pending re-gate label was stale. **Severity:** MEDIUM **Component:** retained UI / chat commands / net @@ -11746,7 +12053,8 @@ in-world SmartBox readout. ## #212 — Toolbar shortcut numbers turn gray in physical combat -**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate +**Status:** CLOSED 2026-08-28 — implementation and regression coverage are +present; the pending-gate label was stale. **Severity:** LOW **Component:** retained UI / toolbar @@ -11853,7 +12161,9 @@ combat mode, and the jump bar still fills and hides normally. ## #209 — Retail jump power bar missing -**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live visual gate +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** IN-PROGRESS — implementation complete 2026-07-13, pending live visual gate **Severity:** MEDIUM **Component:** retained UI / movement @@ -11883,7 +12193,9 @@ the jump starts. Repeat after moving/resizing the bar and after relogging. ## #208 — Combat bar appears after logging in peacefully -**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate **Severity:** MEDIUM **Component:** retained UI / combat lifecycle @@ -12045,9 +12357,11 @@ sequencer identity across the update. User verified the exact equip/unequip flow in the Release client. ## #191 — Tapping W (brief forward press) glides forward without playing the step animation +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. ## #194 — WbDrawDispatcher._groups is never pruned (minor) -**Status:** OPEN — filed 2026-07-10. `post-M2`, LOW priority. Surfaced during the #193 heap analysis. +**Status:** CLOSED 2026-08-28 — `BeginFrame` prunes old instance groups and +current regression coverage pins the bounded lifetime. **Severity:** LOW — bounded, not a crash risk (unlike #193 which was the real OOM). **Component:** render — `WbDrawDispatcher._groups` (`Dictionary`, WbDrawDispatcher.cs:236). @@ -12055,7 +12369,9 @@ in the Release client. ## #193 — Client OOMs after extended play (~50 min) — FIXED -**Status:** ✅ FIXED + measurement-verified 2026-07-10 (`119a2326`). Root cause: `WbDrawDispatcher.InstanceGroup.Opacities` (a `List` added by #188) was appended one float per drawn instance per frame but never cleared — the per-frame reset loop (WbDrawDispatcher.cs:959) cleared its four sibling parallel lists but not Opacities. `List` capacity-doubling → ~128 MB/512 MB LOH `float[]` → ~1 GB/min → OOM after ~50 min. Fix: extract the reset into `InstanceGroup.ClearPerInstanceData()` clearing ALL FIVE parallel lists (so a future 6th can't drift out); TDD `InstanceGroupClearTests`. **Before/after (same 6-min churny roam, dotnet-counters):** working set 1.6→7.6 GB (leaked) vs 0.75→1.36 GB (fixed); LOH 1.1→6.1 GB (climbing) vs 0.24→0.64 GB then FLAT (240→607→641→641→641). No crash on the fixed build. (Move to Recently closed on next tidy.) +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** ✅ FIXED + measurement-verified 2026-07-10 (`119a2326`). Root cause: `WbDrawDispatcher.InstanceGroup.Opacities` (a `List` added by #188) was appended one float per drawn instance per frame but never cleared — the per-frame reset loop (WbDrawDispatcher.cs:959) cleared its four sibling parallel lists but not Opacities. `List` capacity-doubling → ~128 MB/512 MB LOH `float[]` → ~1 GB/min → OOM after ~50 min. Fix: extract the reset into `InstanceGroup.ClearPerInstanceData()` clearing ALL FIVE parallel lists (so a future 6th can't drift out); TDD `InstanceGroupClearTests`. **Before/after (same 6-min churny roam, dotnet-counters):** working set 1.6→7.6 GB (leaked) vs 0.75→1.36 GB (fixed); LOH 1.1→6.1 GB (climbing) vs 0.24→0.64 GB then FLAT (240→607→641→641→641). No crash on the fixed build. (Move to Recently closed on next tidy.) **Severity:** was MEDIUM→HIGH (crashed after extended sessions). RESOLVED. **Component:** render — `WbDrawDispatcher` per-frame instance-group reset. @@ -12068,7 +12384,9 @@ in the Release client. **How to investigate (capture-first, do NOT guess):** measure ACTUAL memory growth over time — managed heap (`GC.GetTotalMemory` / dotnet-counters) AND unmanaged/GPU (native heap, GL texture/buffer/mesh allocations, the WB mesh caches, `TextureCache`, `GlobalMeshBuffer`). Prime suspects to rule in/out with real measurements: GPU resource accumulation at the High preset (aniso16x/MSAA4x/25×25 far window), per-revisit landblock/mesh/texture cache growth, or a genuine managed leak in the entity/streaming path. Snapshot the working set every N minutes of a roam and diff. Possible contributor to tonight's phantom-door-regression episode (memory pressure → GC thrash → dropped inbound motion packets) — see `feedback_phantom_regression_runtime_state.md`. -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** MEDIUM (visible every time a player taps instead of holds a movement key — likely a common input pattern) **Filed:** 2026-07-09 @@ -12338,7 +12656,7 @@ Then Slice 2 (unify Path A + `RemotePhysicsUpdater` extraction) and Slice 3 (Set ## #183 — Floating distant scenery: trees from another biome hover in the distance -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-confirmed during ledger cleanup. **Severity:** LOW **Filed:** 2026-07-07 **Component:** rendering / scenery / streaming @@ -12362,7 +12680,9 @@ the wrong scene set, or a far-LOD placement offset. Not yet investigated. ## #182 — Player wedges in a packed monster crowd, can't wiggle free (hand-rolled SphereCollision) -**Status:** VELOCITY-MODEL REBUILD SHIPPED (Slices 1+2, `8bb8b204`→`54d56229`) — +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** VELOCITY-MODEL REBUILD SHIPPED (Slices 1+2, `8bb8b204`→`54d56229`) — **awaiting the user visual gate** (crowd glide/land + normal-locomotion regression pass). **Severity:** MEDIUM **Filed:** 2026-07-07 @@ -12453,7 +12773,9 @@ artifact's LOOK (brightness) without killing the motion-flicker — re-character before assuming tonight's captures still describe it. **Original filing (mechanism evidence trail below remains valid):** -**Status:** OPEN — mechanism PINNED from live evidence; the knife-edge test not yet identified +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN — mechanism PINNED from live evidence; the knife-edge test not yet identified **Severity:** HIGH (THE user-visible #176 flicker that survived the #180 camera fixes: washed regions with hard screen-space rectangle boundaries pulsing at a parked camera) **Filed:** 2026-07-06 (split from #176 after the #180 fixes exonerated the camera) @@ -12571,7 +12893,8 @@ pixel diffs show no region-shaped changes; the user sees no flicker; #176 re-gat ## #180 — Camera-collision sweep bistable at a compressed boom → per-frame eye strobe (the #176 "stripes") -**Status:** 🟡 BOTH FIXES SHIPPED 2026-07-06 + LOG-VERIFIED; user gate pends on #181 +**Status:** CLOSED 2026-08-28 — both fixes shipped and were log-verified; the +remaining visual was tracked separately in now-closed #181. (the residual visible flicker turned out to be render-side — see #181). Fix 1 `48aaab81`: stateful sought-position per `CameraManager::UpdateCamera` 0x00456660 (pseudocode `docs/research/2026-07-06-camera-sought-position-pseudocode.md`; register @@ -12633,7 +12956,7 @@ stay continuous in `[flap-sweep]`); camera glides along walls like retail. ## #178 — Retire the A8 double-sided cell-shell stopgap (CullMode.Landblock → None) -**Status:** OPEN +**Status:** CLOSED — USER-ACCEPTED 2026-08-28 **Severity:** LOW-MEDIUM (correctness/perf debt; 2× shell fragment load) **Filed:** 2026-07-06 **Component:** render — EnvCellRenderer MDI draw @@ -12654,6 +12977,26 @@ investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`). **2026-07-09 triage:** investigated, verdict STILL_OPEN — the `CullMode.Landblock -> CullMode.None` double-sided stopgap is still present verbatim at `EnvCellRenderer.cs:1394-1399` with no follow-up commit or roadmap reference retiring it. +**2026-08-28 fix:** named-retail proves that the CellStruct polygon +`sides_type` is a geometry-expansion instruction, not a GPU cull mode: +`D3DPolyRender::ConstructMesh @ 0x0059DFA0` expands the authored faces and +`RenderMeshSubset @ 0x0059CA10` draws the constructed mesh with +`D3DCULL_CW`. Our extractor already emits retail's exact fans and reversed +faces. Both production EnvCell paths now use the corresponding clockwise +cull state, and the two `Landblock -> None` overrides are gone. The installed +DAT audit covered 38,189 polygons in 3,168 CellStructs with no invalid side +types or missing vertices. Hermetic winding/cull-policy tests and the +canonical Release gate pass: 16,321 passed, 0 skipped, 0 failed. No PAK +rebake is required. Research: +`docs/research/2026-08-28-issue178-cell-shell-culling.md`. + +**Gate owed:** owner visual acceptance at Holtburg and the Facility Hub; +walls, floors, ceilings, ramps, and stairs must remain visible from every +ordinary playable camera angle. + +**Owner gate 2026-08-28:** PASSED — “Ok looks good.” Holtburg/Facility-Hub +interior shell acceptance is complete; #178 is closed. + --- ## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down) @@ -12850,7 +13193,9 @@ over one session as CreateObject re-sends re-registered it under fresh entity ids — `[seam-ent]` L= showed `01D4:I100` four times) — fold into the A7 arc or fix with #180's gate.** -**Status:** 🟡 lighting fix SHIPPED + verified; #180 (camera strobe) BOTH halves +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 lighting fix SHIPPED + verified; #180 (camera strobe) BOTH halves FIXED + log-verified 2026-07-06 (`48aaab81` + `f10fe4e9`); the site-A static light-stacking re-apply hole CLOSED (`87cddce2` — idempotent re-registration; whether it fully accounts for the observed `[seam-ent]` ×2→×4 growth needs a @@ -12869,7 +13214,9 @@ render; the physics fix landed (seam shake gone, user-gated) and the flash REMAINS — so it is a render-side issue in its own right, correlated with camera angle. -**Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7 +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7 dungeon-lighting arc (see #177 for the revert story — same mechanism, same deferral). **Root cause (confirmed via the probe launches):** per-cell LIGHTING pops, @@ -12899,7 +13246,9 @@ camera angle at the corridor seams. ## #175 — Door collision registers the Setup PLACEMENT pose, not the motion-table CLOSED pose (phantom slab behind the visual door) -**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed). **FIX:** `ShadowShapeBuilder.FromSetup` gains a `partPoseOverride` (BSP part shapes only; CylSphere/Sphere unchanged); `RegisterServerEntityCollision` derives it via `GameWindow.MotionTableDefaultPose` — the wire MotionTableId's @@ -12961,7 +13310,9 @@ unregressed (door apparatus green). ## #174 — Door Use dies after the first jump: the RemoveLinkAnimations seam stripped animations without retail's queue drain -**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m). **FIX:** the `MotionInterpreter.RemoveLinkAnimations` seam is retail `CPhysicsObj::RemoveLinkAnimations` 0x0050fe20 — a tailcall to `CPartArray::HandleEnterWorld` 0x00517d70 → `MotionTableManager:: @@ -13067,7 +13418,9 @@ is verbatim retail), no deferral-skipping (turn-to-face is retail). ## #173 — Observed character jumping into a ceiling hovers at the roof until the arc decays (no collision-velocity response on remotes) -**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player). **Severity:** MEDIUM (remote-motion fidelity indoors; lands visibly late) **Filed:** 2026-07-05 **Component:** physics — remote dead-reckoning collision response @@ -13109,7 +13462,9 @@ unchanged. ## #172 — Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported) -**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run). **Severity:** HIGH (blocks dungeon access — gates the whole #137 repro) **Filed:** 2026-07-05 **Component:** physics — CylSphere object collision response @@ -13474,7 +13829,9 @@ actually steps at (Player_Move.cs / Creature GetRunRate usage). ## #165 — Remote entities penetrate walls ("swallowed a bit") before stopping -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** MEDIUM (visual-only, remote view) **Filed:** 2026-07-03 (user observation during the R2-R4 visual pass) **Component:** physics, remote dead-reckoning @@ -13557,7 +13914,9 @@ wall from acdream, matching the retail-observer view side-by-side. ## #166 — Slope-landing glide + bounce absent (retail "sled" on downhill jumps) -**Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the user's visual-gate acceptance. The visual-matrix recheck this note asked for DID happen (Campaign P matrix scenario 5) and found the glide/bounce still missing even with all four register-predicted deviations @@ -13733,7 +14092,9 @@ R5/R6 touches the action list. **Where:** ## #202 — Port the portal String-table lookup for WeenieError / UseDone text -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-07-03 **Component:** ui / net @@ -13748,7 +14109,8 @@ R5/R6 touches the action list. **Where:** ## #195 — Retail chat ChatVM lacks Fps/Position providers — /framerate and /loc degrade -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — obsolete architecture; the duplicate +ChatVM/provider shape is gone and local commands use the dedicated route. **Severity:** MEDIUM **Filed:** 2026-07-02 **Component:** ui @@ -13851,7 +14213,8 @@ states `0x28` and `0x29`. ## #199 — Port the server-authoritative character raise flow -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — Campaign CA replaced optimistic mutation with +the authoritative one-request-in-flight response flow. **Severity:** MEDIUM **Filed:** 2026-07-02 **Component:** ui / core @@ -13870,7 +14233,8 @@ states `0x28` and `0x29`. ## #200 — Migrate remaining retail-window mounts (vitals/chat/toolbar/inventory + MockupDesktop) to RetailWindowFrame -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — stale two-stack migration list; its inline +mounts and MockupDesktop targets no longer describe current composition. **Severity:** LOW **Filed:** 2026-07-02 **Component:** ui @@ -13915,7 +14279,9 @@ BCL-only. ## #158 — Character window — deferred polish -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-06-26 **Component:** ui @@ -13971,7 +14337,9 @@ passed in the connected visual gate. ## #151 — Far-town (Arwic) collision broken at login: terrain barely grounds + city/perimeter walls never block -**Status:** 🟡 city/perimeter walls **FIXED + user-verified** (`9743537`, 2026-06-24); terrain-grounding sub-question OPEN but re-scoped LOW (likely not a real defect). +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** 🟡 city/perimeter walls **FIXED + user-verified** (`9743537`, 2026-06-24); terrain-grounding sub-question OPEN but re-scoped LOW (likely not a real defect). **Severity:** MEDIUM (walls were the HIGH part — fixed; terrain residual unverified) **Filed:** 2026-06-24 **Component:** physics — building collision-shell registration (walls); far-town terrain grounding (residual) @@ -14148,7 +14516,9 @@ collision working. Also re-checks **#138**. ## #148 — Status-bar backpack icon should toggle the inventory window (stateful open/closed) -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** MEDIUM **Filed:** 2026-06-22 @@ -14181,7 +14551,9 @@ D.2b inventory visual gate. ## #146 — D.2b inventory capacity-bar visual polish -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-06-22 @@ -14266,7 +14638,9 @@ See divergence register **AP-59**. ## #144 — Empty item-slot press+drag+release still emits a Click -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-06-20 **Component:** ui — D.2b drag-drop spine (B.1) @@ -14330,7 +14704,9 @@ See divergence register **AP-59**. ## #139 — D.2b retail UI polish: chat buttons -**Status:** OPEN (narrowed 2026-08-09 — the chat-text-colors half CLOSED, see below) +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN (narrowed 2026-08-09 — the chat-text-colors half CLOSED, see below) **Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client) **Filed:** 2026-06-16 **Component:** ui — D.2b retail UI (chat buttons) @@ -14689,7 +15065,9 @@ neighbour load/unload churn). ## #104 — Scene VFX particles not clipped to the PView visible cell set -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW **Filed:** 2026-06-02 **Component:** render, vfx @@ -14726,7 +15104,9 @@ pass, deliberately deferred out of the Phase W seal (which covers sky/terrain/wa ## #102 — A8.F PortalVisibilityBuilder — port retail update_count fixpoint (replace MaxReprocessPerCell cap) -**Status:** PARTIALLY RESOLVED (Phase U.2a, 2026-05-30, commit `d880775`) +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** PARTIALLY RESOLVED (Phase U.2a, 2026-05-30, commit `d880775`) **Severity:** MEDIUM → LOW (residual is diamond-topology clip-completeness only) **Filed:** 2026-05-29 **Component:** rendering, visibility, EnvCell portal traversal @@ -14984,7 +15364,9 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783` ## #94 — Held items project spotlight on walls -**Status:** OPEN — **UNBLOCKED 2026-07-11.** Retail ParentEvent/CreateObject child +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN — **UNBLOCKED 2026-07-11.** Retail ParentEvent/CreateObject child parenting now renders hand-held objects and follows the animated attachment part. The original lighting symptom can now be reproduced and investigated after the held-item visual gate; no lighting conclusion has been drawn yet. @@ -15526,7 +15908,8 @@ divergence and update commit messages / code comments to match. ## #73 — Retail-message centralization plan — per-feature string sweeps -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — retained as an engineering policy, not a +concrete client defect. **Severity:** LOW (per-feature work, not infrastructure) **Filed:** 2026-05-16 **Component:** ui / retail messages @@ -15755,7 +16138,9 @@ doors work after the heartbeat bump. ## #64 — Local-player pickup animation does not render -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-confirmed after validity audit. + +**Previous status:** OPEN **Severity:** LOW (visual feedback only — pickup completes correctly) **Filed:** 2026-05-14 (B.5 visual verification) **Component:** motion / animation routing for local player @@ -15995,7 +16380,10 @@ for full evidence and rationale. ## #55 — Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — stale pre-#128 observation. The point-of-use +mesh re-arm in `WbMeshAdapter.EnsureLoaded` removed the permanent-miss path; +the later broken-stairs connected evidence records `meshMissing=0` with +`entSeen == entDrawn` while the affected content was on screen. **Severity:** LOW (no visible regression — affects a diagnostic counter, not rendered output) **Filed:** 2026-05-11 **Component:** rendering / `WbDrawDispatcher` static-entity classification path @@ -16635,7 +17023,9 @@ the local terrain normal, not the actor's facing. ## #41 — Residual sub-decimeter blips on observed player remotes (M3 baseline) -**Status:** FIX IMPLEMENTED 2026-07-17 — pending acdream-observer visual gate +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIX IMPLEMENTED 2026-07-17 — pending acdream-observer visual gate **Severity:** MEDIUM **Filed:** 2026-05-05 **Component:** physics / motion / animation (per-tick remote prediction) @@ -17715,7 +18105,8 @@ additional flash-shader work). ## #3 — Client clock drifts from retail after ~10 minutes (periodic TimeSync missing) -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — periodic TimeSync is parsed and routed into +the world clock; the old missing-clock premise is no longer current. **Severity:** MEDIUM **Filed:** 2026-04-25 **Component:** net / sky @@ -18169,7 +18560,9 @@ flip-a → []; flip-b → [0x100]. ## #114 — Indoor PView shell-clip regions are not draw-quality (clip scoped to outdoor roots) -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** MEDIUM-HIGH (blocks the indoor half of retail's draw-side portal clip; several user-visible indoor artifacts to re-test ride on it) **Filed:** 2026-06-11 (first user gate on `927fd8f`) @@ -18204,7 +18597,9 @@ clip against the accumulated portal view (`planeMask=0xffffffff` :427922). ## #115 — Camera feels draggy/jittery vs retail when turning in cramped interiors -**Status:** OPEN +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN **Severity:** LOW-MEDIUM (feel; no geometry errors reported) **Filed:** 2026-06-11 (user, same gate session) **Component:** camera (collision sweep / smoothing) @@ -18221,7 +18616,9 @@ retail's viewer-distance smoothing (update_viewer region) before touching. ## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop -**Status:** OPEN (narrowed further, 2026-07-30) — **shape-2 CLOSED** +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** OPEN (narrowed further, 2026-07-30) — **shape-2 CLOSED** (D4 un-skipped and passing, oracle-plan-confirmed, no cdb needed after all — see the 2026-07-30 update below); **shape-1 narrowed, not closed**: a real, independently-decomp-confirmed Path-6 head-sphere fix landed, but @@ -18628,7 +19025,9 @@ re-validate against the real resolver before un-skipping. ## #120 — [pv-ERROR] in-place propagation tripwire: convergence invariant broken at depth 128 (cottage interior cells) -**Status:** FIXED 2026-06-11 (`dede7e4`) — pending re-gate (watch for +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIXED 2026-06-11 (`dede7e4`) — pending re-gate (watch for zero `[pv-ERROR]` lines in the next launch log) **Severity:** HIGH (self-detected invariant break in the new flood growth) **Filed:** 2026-06-11 (T5 launch log; fired during normal cottage play) @@ -18681,7 +19080,9 @@ Revisit on the next firing (the #117/#118 re-gate launch will carry it). ## #121 — All world portals invisible (portal swirl VFX gone everywhere) -**Status:** FIXED 2026-06-11 — pending re-gate +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIXED 2026-06-11 — pending re-gate **Severity:** HIGH (user: "all portals that were previously showing at various places are now gone") **Filed:** 2026-06-11 (re-gate launch) @@ -18908,7 +19309,9 @@ staircase entity's per-frame draw decision. ## #129 — Doors/doorways leak through terrain and houses from over a landblock away -**Status:** FIX SHIPPED — awaiting user visual gate +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIX SHIPPED — awaiting user visual gate **Severity:** MEDIUM (visible at distance during normal outdoor play) **Filed:** 2026-06-12 (user report, post-#119-close session) **Component:** render — aperture depth punch at distance (#117 family, AD-18) @@ -18945,7 +19348,10 @@ cap constant (0.5 m) is the tuning knob — see AD-18. ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside -**Status:** FIX 2 SHIPPED — awaiting user visual re-gate +**Status:** CLOSED AS OWNER-ACCEPTED RESIDUAL 2026-08-28 — the connected +visual re-gate confirmed the thin top-edge strip still exists; owner direction +is to leave it as-is. The two shipped correctness fixes and their regression +coverage remain, but no further rendering change is requested. **Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) **Filed:** 2026-06-12 (user report, post-#119-close session) **Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32) @@ -20746,7 +21152,7 @@ visible seam lines that formed a cube outline across the view. ## #316 — The player arm's LANDING TRANSITION block never publishes the collision shadow -**Status:** OPEN +**Status:** FIXED 2026-08-28 **Severity:** UNKNOWN pending one measurement (see "The open question") — either a ~33 ms shadow lag (cosmetic, invisible in practice) or the #184 invisible-but-solid class (real). Do not act on it before measuring. @@ -20799,9 +21205,17 @@ by `LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved` / `LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared` in `tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs`. +**Resolution:** The explicit player-GUID skip is removed. Every accepted +`AirborneSnap` now publishes the resolved body pose through the common +collision-shadow tail. The player landing regression test proves the shadow +lands at the authoritative pose rather than retaining its spawn pose; the +complete 27-row collapse matrix passes. + ## #319 — A player-parented child never receives a canonical cell (ParentInstanceSequence hardcoded 0) -**Status:** FIX IMPLEMENTED, awaiting the connected acceptance gate (§7 of the +**Status:** CLOSED 2026-08-28 — owner-directed ledger cleanup. + +**Previous status:** FIX IMPLEMENTED, awaiting the connected acceptance gate (§7 of the contract) and commit — NOT YET COMMITTED in this worktree. Do not mark DONE until the gate runs and the change lands. **Severity:** LOW for the user (no observable symptom — verified, not assumed), @@ -20922,7 +21336,9 @@ Full analysis, with the headless test's structural immunity explained: ## #321 — `DatSoundCacheTests` concurrent-decode-dedup fails under full-suite load -**Status:** OPEN +**Status:** FIXED 2026-08-28 — a stale miss now rechecks resident/negative +publication and acquires the in-flight decode under the admission gate. A +deterministic paused-stale-caller test proves only one DAT read/decode occurs. **Severity:** LOW (test-only so far; no production symptom observed) **Filed:** 2026-08-05 **Component:** content / audio cache @@ -20956,7 +21372,13 @@ strictly worse than a red test. ## #322 — Two callers compute the same two pre-placement flags from the same two inputs -**Status:** OPEN +**Status:** FIXED 2026-08-28. A single +`RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags` +function now owns retail's disposition/HasAnims truth table. Both the +steady-state snapshot merge and every accepted classifier route consume it; +the route itself was not widened into the merge. The former equality test is +re-argued as an end-to-end application matrix plus an explicit six-row truth +table. Controller, classifier, and continuation-executor tests pass 169/169. **Severity:** LOW (internal refactor debt; NOT a retail divergence) **Filed:** 2026-08-05 (C5b review, finding S1 — the successor #275 closed without filing) @@ -21003,7 +21425,9 @@ finding is that these two flags need no route, no `playerDistance` and no ## #323 — A far-snap store can silently stale a pending initial-create completion receipt -**Status:** OPEN +**Status:** FIXED 2026-08-28 — initial-create completion now proves the retained +body's exact pose still equals the captured receipt before applying it. A +pose-only supersession acknowledges without snapping the sidecar back. **Severity:** LOW (narrow, self-healing within one broadcast interval; no observed live symptom) **Filed:** 2026-08-05 (C5b review, finding D2 — found while establishing that diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0fec343b..a7716216 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -1,4 +1,4 @@ -# Retail Divergence Register — current through 2026-08-26 +# Retail Divergence Register — current through 2026-08-28 **What this is.** The single auditable register of every known place acdream's runtime behavior can deviate from the retail client (Sept 2013 EoR build, @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 89 active rows (AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 88 active rows (AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -196,7 +196,7 @@ readiness/requeue adaptation. See | AD-85 | **Filed 2026-08-12 at Campaign FA slice FA5. NARROWED 2026-08-13 (social gate round 2):** the row's items 2 and 3 — the three LOCAL Swear/Break/Kick confirmation dialogs and the server-driven type-1 accept-swear dialog (plus the type-4 fellowship invite) — are PORTED: `DatStringResolver.ResolveTemplate` composes the exact `0x23000001` templates (`ID_Allegiance_SwearConfirmation`/`BreakConfirmation`/`KickConfirmation`, `ID_Allegiance_AcceptSwearConfirmation`, `ID_Fellowship_FellowshipRequest`) by the `StringTable::GetString @0x004300D0` fragment/PLAYER-variable interleave (no-metalanguage branch `@0x004303B7`; all five templates verified token-free — `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §1.2). What REMAINS recorded: item 1 — the numeric fields: self/monarch followers (`0x10000252`/`0x10000258`) and self rank (`0x10000253`) now carry `Followers:`/`Rank: [n]` label text but not retail's `StringInfo`-resolved sentence, and the "experience passed up" text (`0x10000492` ×2, the vassal row's `0x10000269`) renders bare numbers, same disposition as AD-81's `"{level} {pct}%"`. Those templates are multi-variable and were not verified token-free; they can move onto `ResolveTemplate` after the same verification. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshSelfBlock`, `RefreshMonarchBlock`, `RefreshPatronBlock`, `UpdateRow`) | Same argument as AD-81 for the remainder: the numeric-field templates have not been dumped/verified token-free, and guessing meta-token behavior would risk silently-wrong substitution. The dialog templates WERE verified, which is why they moved. | A user sees bare numbers instead of retail's full sentences for followers/rank/XP-passed-up. The confirmation dialogs now read retail's full sentences ("Do you wish to swear to X?", "X would like to swear allegiance to you. Do you accept?"). | `gmAllegianceUI::UpdatePlayerData @0x00491330`, `UpdateMonarchData @0x00491B40`, `UpdatePatronData @0x004917C0`, `UpdateVassalsData @0x00492340` (lane C/A field sources); `MakeSwearConfirmationDialog @0x004927B0` family (lane A §5.1); `StringTable::GetString @0x004300D0` (ported for token-free templates); `StringTableMetaLanguage::RenderString @0x004302B1` (still unported — AD-81) | | AD-86 | **Filed 2026-08-12 at Campaign FA slice FA5, item 4.** ACE deliberately zeroes or empties NINE `AllegianceProfile`/`AllegianceData` fields on the wire — officers, officer titles, MOTD, MOTD-set-by, name-last-set-time, lock state, and approved vassal are always empty/false/zero regardless of the allegiance's real state; `timeOnline`/`allegianceAge` (the remaining two) are hard-coded 0 forever (lane C §5.1). acdream's FA1 parser reads all of these (to keep the byte cursor aligned for the fields after them) but drops most at increasing layers: `AllegianceMemberRecord` never surfaces `timeOnline`/`allegianceAge` as fields at all; `RuntimeAllegianceState.ApplyUpdate` (FA2) does not forward `Motd`/`MotdSetBy`/`ChatRoomId`/`NameLastSetTime`/`IsLocked`/`ApprovedVassal` from the parsed `AllegianceUpdate` record to `RuntimeAllegianceSnapshot` even though the C# record itself carries them; retail's own `gmAllegianceUI` (FA5) has no widget for any of the seven either (lane A §3.3: "No allegiance MOTD / officer / ban / hometown UI" — they are chat-verb-only in the 2013 client, out of this campaign's scope per the plan's §4). | `src/AcDream.Core.Net/Messages/ClientCommandResponses.cs` (`ReadAllegianceProfileBody`, `AllegianceMemberRecord`, `AllegianceUpdate`); `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyUpdate`) | Retail's own client renders nothing for these seven fields either (no panel widget consumes them) — dropping them past the parse layer matches retail's OWN presentation exactly, and is strictly safer than surfacing values that are always wrong/empty against ACE. | Any FUTURE consumer (the chat-verb-only officer/MOTD/lock/ban management features, §2 master table features #11-31 of the allegiance wire research, explicitly out of Campaign FA's scope) that reads these fields off the Runtime layer will find them permanently zero/empty against ACE regardless of the allegiance's real server-side state — do not chase this as a parser bug; it is ACE's own zeroing. | ACE `Network/Structure/AllegianceHierarchy.cs:53-56,62-64,74-75,78-83,86-89,153-155` (broadcast counters/isLocked/officers/officerTitles/motd/approvedVassal); ACE `Network/Structure/AllegianceData.cs:59-60,86-89,111-112` (timeOnline/allegianceAge); `docs/research/2026-08-11-fa-allegiance-wire.md` §5.1 | | AD-87 | **Filed 2026-08-12 at Campaign FA slice FA6.** The allegiance-swear half of the two-bot headless connected gate (`FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) is written and wired end-to-end (proximity, `0x001D` swear, the confirmation-relay seam, `0x0020` tree-reseed assertions, break, reconnect-idempotence) but has never actually been verified to complete over the wire — `AllegianceGateEnabled = false` in both classes keeps it unreachable by default. Six live runs against local ACE all reproduced the same result: the fellowship half passes decisively (the Recruit bot's own `RuntimeFellowshipState` flips, proven three separate times), but ACE returns nothing at all to the `0x001D` swear (no `0x0274` confirmation, no `0x0020`, no error) even at 0.005 m separation — see docs/ISSUES.md #384 for the full evidence trail. So while the FELLOWSHIP two-session machinery is proven live, the ALLEGIANCE two-session machinery (Runtime commands, wire builders, `RuntimeAllegianceState` reseed) remains unverified end-to-end over a real connection — only its unit/fixture-level tests and its (successful) LOCAL echo on the swearer's own client are exercised. | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` (`FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled`, `FellowshipAllegianceRecruitBotPolicy.AllegianceGateEnabled`, both `false`) | Shipping the fellowship gate ALONE (rather than blocking the whole slice on the allegiance blocker) matches the campaign's own D8/item-6 split — fellowship and allegiance are independent retail systems with independent wire families, and the fellowship half's proof stands on its own regardless of the allegiance outcome. Disabling rather than deleting the allegiance code keeps a reviewed-quality, ready-to-run harness in place for whoever closes #384. | Anyone reading "the FA6 bot-vs-ACE gate passed" without the qualifier could assume the allegiance swear/break/reconnect path is proven over the wire when it is not — only its LOCAL send-and-echo behavior is proven; ACE's actual acceptance of the swear is the open question #384 tracks. | docs/ISSUES.md #384; `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 (the expected `0x0274`/`0x0275`/`0x0020` handshake); run6 evidence (0.005 m distance, zero inbound after swear) | -| AD-88 | **Filed 2026-08-13 at the #385 dropdown fix (classification: UNCLEAR).** The vendor category dropdown ships G5's fixed 6-row scrollable popup window, but its authored popup ListBox (`0x21000043/0x10000350`) is edge-docked on all four sides (L=T=R=B=1, measured by menuprobe3 `OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`) — the exact authored condition that arms retail `UIElement_Menu::RecalculatePopupSize @0x0046caf0`, which resizes the popup to the ListBox's summed content height, uncapped (`0x0046e5f4..0046e66c`). The Config option-menus' identical docked shape now drives `UiMenu.PopupSizeToContent=true` (#385); vendor deliberately keeps `false`. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (its UiMenu wiring leaves `PopupSizeToContent` at the class-default false) | The G5 vendor-gate retail screenshot was read as a ~6-row-with-scrollbar look and the vendor connected gate USER-PASSED on that shape — reworking a user-gated surface on decomp inference alone would invert the retail-oracle rule. The two pieces of evidence conflict; the row records the conflict rather than silently picking a side. | If retail actually opens the category popup full-height, our vendor dropdown shows a 6-row scroll window where retail shows every category at once — visible at any vendor with >6 categories. If retail truly shows 6 rows, the mechanism question (why the docked ListBox does not trigger RecalculatePopupSize there) is unanswered and could mislead the next dropdown port. | docs/ISSUES.md #386 (the retail side-by-side to run + the two candidate resolutions); #385 (the Config fix that exposed the conflict) | +| ~~AD-88~~ | **RETIRED 2026-08-28 (#386).** Named-retail tracing resolved the prior screenshot ambiguity: `VendorItemsUI::OpenVendor @0x004c16d0` inserts only present categories; `UIElement_ListBox::UpdateLayout @0x0046e460` sums their heights; `ResizeScrollableArea @0x00474730` broadcasts message `0x32`; and `UIElement_Menu::ListenToElementMessage` calls `RecalculatePopupSize @0x0046caf0`. The four-edge-docked vendor ListBox therefore resizes the popup to all present rows, uncapped. Installed-DAT inspection additionally found scrollbar `0x10000351` property `0x79=true` (`HideWhenDisabled`). Same-day visual re-gate found acdream's flattened popup still reserved the hidden sibling's 16-pixel width; the shared presentation predicate now also collapses that width to zero, matching the final effect of retail `UIElement_Scrollbar::UpdateLayout @0x004710d0` calling `SetVisible(false)`. | `src/AcDream.App/UI/UiMenu.cs` (`PopupSizeToContent`, `PopupScrollbarHideWhenDisabled`, shared presentation gate); `src/AcDream.App/UI/Layout/VendorUiController.cs` (both authored behaviors enabled) | The purported fixed-six-row retail screenshot was a misread, and the proposed "items inserted before message registration" explanation was disproven: `MakePopup` registers popup element messages during initialization, before `OpenVendor` inserts categories. | None. Short vendor lists now shrink without the striped scrollbar artifact; long lists grow to every category exactly through the retail content-sizing rule. | docs/ISSUES.md #386; `VendorItemsUI::OpenVendor @0x004c16d0`; `UIElement_ListBox::UpdateLayout @0x0046e460`; `UIElement_Menu::RecalculatePopupSize @0x0046caf0` | | AD-90 | **Filed 2026-08-13 at the #389 mechanism-review fix round (finding M1).** Retail's smartbox divisor aspect is not raw width/height: `RenderDevice::ComputeAspectForViewport @0x0054f150` yields `(w/h) × m_DisplayAspectRatio × 0.75`, with `m_DisplayAspectRatio` fed by the registered `Render.AspectRatio` preference. At that preference's DEFAULT (4:3) the factor is exactly 1.0f and the expression collapses to raw w/h — which is what acdream uses. acdream carries no AspectRatio preference at all. Also folded in: retail's `SetFOVRad` gate arithmetic ACCEPTS NaN (x87 unordered-compare quirk) where acdream's port rejects it — unreachable in practice, deliberately not reproduced (mechanism review M3). | `src/AcDream.App/Rendering/RetailFieldOfView.cs` (class doc names this row) | Bit-exact at retail's registered default; the preference existed for 2003-era stretched-CRT correction with no modern counterpart. Reproducing it would add a user knob retail itself defaulted away. | A retail user who had changed `Render.AspectRatio` saw framing acdream cannot reproduce; anyone porting FOV behavior from a capture made with a non-default AspectRatio preference will measure a mismatch against our law. | `RenderDevice::ComputeAspectForViewport @0x0054f150`; `Render::SetFOVRad @0x0054b2d0`; consumer `D3DXMatrixPerspectiveFovLH @0x0059ab71`; docs/research/2026-08-13-389-fov-mechanism-review.md | | AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | @@ -217,6 +217,11 @@ readiness/requeue adaptation. See ## 3. Documented approximation (AP) — 162 active rows (AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +**2026-08-28 count correction:** #408 retired AP-230 by moving property +`0x3B` visibility into the shared importer and every retained stateful widget; +the active AP total is **161**. The historical header narrative above retains +the filing chronology, while the retired row below records the final gate. + Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered AP-94..AP-112 for the confirmed retail-UI completion gaps. @@ -242,6 +247,13 @@ double-click-to-buy is absent is superseded by this correction. The active-row count in the heading is therefore one lower than the retained historical heading text. +**AP-148 retirement correction (2026-08-28):** #325 ports Gate A's exact +wrap-safe not-older predicate in both the timestamp gate and Runtime authority +classifier. A newer force stamp now leaves TELEPORT_TS unconsumed, preserves +heading/velocity/parent state, arms no teleport hook, and still acknowledges +immediately. The long AP-148 row below is retained only as historical filing +research and is no longer active. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | @@ -249,7 +261,7 @@ heading text. | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | | AP-231 | **Filed 2026-08-16 at the Campaign CC gate round 1 closeout, Group 2 (Skills page info-box completion).** `CharacterCreationSkillsPage.ComposeFormula` ports `gmCGSkillsPage::MakeSkillFormula @0x00480e10` with HIGH CONFIDENCE for the `"Formula : "` prefix, the per-attribute `"(%u x %s)"`-vs-bare-name choice (a term's own multiplier > 1 gets the parenthesized form, else just the attribute name), the `" / %u"` divisor suffix (gated on `Divisor != 1`), and the `" +%u"` additive-bonus suffix (gated on `AdditiveBonus != 0`) — every one of those is a directly-read compiled string literal or a field the DatReaderWriter binding already exposes by name (`SkillFormula`'s six fields map 1:1 onto the decompiled struct's own `_w/_x/_y/_z/_attr1/_attr2` offsets, confirmed by their exact 0x28/0x2c/0x30/0x34/0x38/0x3c stride). LOWER CONFIDENCE: the CONNECTOR text between a two-attribute formula's two terms. This port renders `" + "` — the well-known "(Attr1 + Attr2) / N" shape most published AC skill formulas use — but the decompiled function's own two candidate connector literals (`data_7a01a4`, appended between the terms; `data_797584`, appended again immediately after BOTH terms are present, an apparently redundant second literal whose exact role this session could not resolve) sit behind reference-counted `PStringBase` appends whose actual wide-character content Binary Ninja's HLIL does not surface as a literal — this session had no live cdb attach and no running Ghidra MCP instance to recover the raw bytes. A two-attribute skill's formula therefore renders as `"Formula : (2 x Strength) + Endurance / 4 +2"`-shaped text that is very likely retail-correct in STRUCTURE but not byte-verified. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`ComposeFormula`, `AppendAttributeTerm`) | The single-attribute majority of skills render byte-correct today; only the minority of two-attribute formulas carry the unverified connector, and the gap is disclosed in the method's own doc rather than silently guessed. | A live retail capture of a two-attribute skill's formula text (e.g. via the cdb toolchain) could reveal `" + "` is wrong — the actual connector might be `" and "`, `" / "` (an OR-style formula, common for some AC skills that use whichever attribute is higher), or something else the two unresolved literals encode; `data_797584`'s role (appended after both terms) is also unexplained and could indicate a THIRD text segment this port omits entirely. | `gmCGSkillsPage::MakeSkillFormula @0x00480e10`; `SkillFormula` struct (`acclient.h`) | -| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. **Second narrow honor added (F5/F6, gate round 1 closeout, 2026-08-16):** `LayoutImporter.BuildWidget`'s Batch C `UiText or UiField` un-consumed-children carve-out now ALSO honors `AuthoredInvisible`, scoped to exactly the children it builds through that one loop — a live-DAT sweep found the chat transcript's new-text indicator (`0x1000048C`) is one of the 37 carve-out (layout, element) pairs' children and authors `Invisible=true` itself, so the carve-out was building it as a visible phantom element retail never shows. Verified in both directions (`MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible` + `MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets`): the chat indicator now builds hidden, and the eight gold-frame pieces this carve-out ALSO covers do not author Invisible and stay visible. Still narrower than #408: only these two honor sites exist (chargen's own screen walk; this one carve-out loop) — every OTHER AuthoredInvisible-bearing element client-wide, reached through the ordinary generic-container recursion, remains data-only. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment AND the `UiText or UiField` carve-out's own honor); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | +| ~~AP-230~~ | **RETIRED 2026-08-28 by #408.** `LayoutImporter.BuildWidget` now applies every effective `P0x3B=true` as initial `Visible=false`; `UiDatElement`, `UiText`, and `UiButton` apply the committed named or DirectState value through their state machines; and the chargen-only walk is deleted. The installed-DAT sweep enumerates 1,019 resolved authored-invisible elements across 38 layouts, builds 990 after normal child consumption, and proves 990/990 start hidden. The complete automated App lane passes 6,511/6,511. | `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/UiDatElement.cs`; `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/UiButton.cs`; `tests/AcDream.App.Tests/UI/Layout/LayoutImporterInvisibleSweepTests.cs` | The old scoped-vs-general implementation split no longer exists. | None for property 0x3B visibility; runtime controllers may intentionally issue later visibility changes exactly as retail does. | `UIElement::OnSetAttribute @0x00462d80` case 8; `UIElement::SetState @0x00464e70` | | AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. **Dialog-as-sibling addendum (F3, gate round 1 closeout, 2026-08-16):** the same flat-sibling-list mechanism that motivates this row ALSO covers `RetailDialogFactory`'s own open dialogs — a dialog's root is a direct sibling of the chargen/character-management screen roots under the SAME `Host.Root`, and `RetailWindowManager.BringToFront` is a simple "highest ZOrder among siblings + 1", so whichever sibling's own `BringToFront` call runs LAST in a frame wins z-order. This was GF-15's actual root cause (a dialog opened while chargen is active got buried the very next frame because the screen's own per-tick `BringToFront` ran after the dialog's one-time open-time raise) and is now closed by `RetailDialogFactory.Tick()` re-raising every open dialog, in `_openOrder`, every tick — but the underlying divergence (dialogs and screens sharing one z-order list at all, where retail's dialog layer is architecturally separate from `UIFlow`'s single current framework) remains; any FUTURE sibling that calls its own unconditional per-tick `BringToFront` could reintroduce the same failure class against a dialog OR against chargen itself. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | | AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | @@ -444,7 +456,7 @@ heading text. | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 50 active rows (TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system), REWRITTEN same-day at the F3 review round — two unported tooltip sub-mechanisms: (1) the `m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0` truncated-text auto-tooltip (187 of 430 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing; the ORIGINAL filing's "dynamic InqProperty(0x49) override" framing was FALSE — retail's own base `InqProperty` reads the same authored bags this port already does, so most of those 187 show nothing in retail too — see the row's own full text for the correction), and (2) the per-element P0x3D wrap-max-width override (RetailTooltipPresenter always wraps at the display width, the confirmed retail fallback — no probed element authors P0x3D); TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 47 active rows (TS-68 retired 2026-08-28 by #360 — complete allegiance/house/MOTD grammar and wire; TS-69 retired 2026-08-28 by #361 — day/log/render now execute locally; TS-85 filed 2026-08-16 at #409 (client-wide retail tooltip system), REWRITTEN same-day at the F3 review round — two unported tooltip sub-mechanisms: (1) the `m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0` truncated-text auto-tooltip (187 of 430 live-DAT-probed tooltip-property-authoring elements have no literal StringInfo text and show nothing; the ORIGINAL filing's "dynamic InqProperty(0x49) override" framing was FALSE — retail's own base `InqProperty` reads the same authored bags this port already does, so most of those 187 show nothing in retail too — see the row's own full text for the correction), and (2) the per-element P0x3D wrap-max-width override (RetailTooltipPresenter always wraps at the display width, the confirmed retail fallback — no probed element authors P0x3D); TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -505,8 +517,8 @@ heading text. | TS-64 | **Retail's sound-preference surface is only partly present.** Retail registers eight `[Sound]` keys in `SoundManager::InitPrefs` @ `0x005503F0`; two are unimplemented in acdream. (a) `s_bPlaySoundOnlyWhenActive` (default **1**) is checked against `Device::m_bIsActiveApp` in every entry point and in both `PlaySoundInternal` overloads, so an unfocused retail client is SILENT; acdream keeps playing when the window loses focus. (b) `s_SoundFeatures == 1` forces pan to dead centre; acdream's `RetailSoundMixer.Mix`/`GetPan` take a `panningEnabled` flag with conformance coverage, but no preference is wired behind it, so panning can never be turned off. The three enable bools (`Sound Disabled`, `Ambient Sound Disabled`, `Interface Sound Disabled`) also have no acdream counterpart — note retail's on-disk polarity is inverted relative to its backing variables, so a future reader must not assume the sense. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (no focus gate); `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`panningEnabled`, unwired) | Slice A2 kept its blast radius on the mixing model: window-focus state and a preference surface are host plumbing rather than mixing math, and the mixer parameter exists so wiring them later needs no math change. | Alt-tabbed acdream keeps making noise where retail goes quiet; users cannot disable panning or the individual sound classes. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::PlaySoundInternal @ 0x0054FEC0` and `@ 0x00550170`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 | | TS-65 | **Volume-squared quirk applied on the ambient path only.** Retail multiplies its volume knob twice on several paths: `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as the `vol` argument and `GetAttenuation` then multiplies by `effect_sound_volume` again, and both `PlayAmbientSound*` entry points pre-multiply by `ambient_sound_volume` before that same second multiply — so those sliders are effectively squared. acdream's `RetailSoundMixer.TryGetAttenuation` applies the knob exactly once (which is what `GetAttenuation` itself does) and the animation-hook path does not pre-multiply. Slice A5 squares the ambient path, where two independent lanes byte-confirmed the double application. | `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`TryGetAttenuation` remarks); `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`Play3DWave`) | Which `PlaySoundA` overload the animation-hook path reaches was not pinned by the lane-1 decode, and inventing a squaring on an unconfirmed overload would change every hook sound's loudness curve on a guess. Single-multiply is the conservative, decoded-function-exact choice; the open question is cheap to settle with a cdb breakpoint on the two overloads. | At a non-unity effect slider, hook sounds are louder than retail (slider 0.5 gives −6 dB where retail gives −12). At the default slider of 1.0 the two are identical, so this is inert until the user moves the slider. | `SoundManager::PlaySoundA @ 0x00550AF0`/`@ 0x00550B70`/`@ 0x005507A0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D12 | | ~~TS-66~~ | **RETIRED 2026-08-08 (Campaign A listening-gate fix; user-reported).** `seen_outside` interiors now keep the OUTDOOR ambient set: the listener source resolves the per-cell `CEnvCell.seen_outside` bit through the physics cache's `CellPhysics` record (the same #107 field `AdjustPosition` reads) and converts the ENVCELL-local origin through the cell's `WorldTransform` into landblock coordinates before the 3×3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is not, and skipping the conversion would centre the walk on a wrong point by up to a landblock. A cell record not yet resident resolves to silence for that rebuild rather than a wrong walk. Sealed interiors (dungeons) remain silent, which is retail-correct. | retired | — | — | `Ambient` gate per `docs/research/2026-08-08-audio-retail-ambient-authoring.md` §6/§8; `CEnvCell::add_ambient_sounds` (folded `ret`); user listening gate 2026-08-08 ("in retail I get both outside ambient and the ambient from indoors") | -| TS-68 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review, Blocker 1.** `@allegiance`/`@all` and `@house`/`@hou` are real retail management-command dispatchers with 12 and 15 subcommands respectively (registry doc §2.5/§2.5b). acdream ports only the subset with simple parameterless/single-field wire shapes (allegiance `info`/`hometown`/`ho`; house `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`). For `@house`, every other subcommand (open, close, storage, remove, boot, boot_all, remove_all, guest, available, hooks, on, off) still falls through to ACE server-passthrough (which replies "Unknown command") — unchanged from the original filing. **The original filing was WRONG for `@allegiance`/`@all`: retail's own `DoAllegiance` never reaches DoChannelCommand/server-passthrough for an unrecognized subcommand** — it prints "Please see @help Allegiance for more information on how to use this command." locally (`label_57da4b`, 0x0057DA4B) and stays entirely client-side. **Corrected again 2026-08-09 at the CH4 re-review, SHOULD-FIX 3.** Retail does NOT refuse boot/ban/officer/title/motd/name/lock/house/chat/broadcast — `DoAllegiance`'s dispatcher table EXECUTES each one locally through its own handler (e.g. `DoAllegianceBoot @ 0x0057D646` is the dispatcher's call site into `ClientCommunicationSystem::DoAllegianceBoot`; `DoAllegianceBan`/`DoAllegianceOfficer`/`DoAllegianceOfficerTitle`/`DoMotd`/`DoAllegianceName`/`DoAllegianceLock`/`DoAllegianceHouse` are its siblings in the same table). acdream has none of those nine handlers ported (tracked by issue #360) and instead shows the SAME unrecognized-subcommand refusal ("Please see @help Allegiance...", `label_57da4b`, 0x0057DA4B) for every one of them, pending the #360 port. What matches retail here is the OWNERSHIP RULE — the verb never reaches `DoChannelCommand`/server-passthrough for `@allegiance`/`@all` regardless of subcommand — NOT the subcommand's actual behavior, which retail executes and acdream does not yet. This still closes the real bug the original filing named (the unmatched subcommand text broadcast to the Allegiance chat channel, 0x02000000). The standalone `@motd` verb (reached directly, not via `@allegiance motd`) remains a separate, still-open gap. `RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`) | Retail would execute these locally (with its own usage/confirmation/refusal text). House's unported subcommands still reach ACE, which does not implement them as chat commands either — no functional loss on a real server, but a user typing e.g. `@house open` gets ACE's generic "Unknown command" instead of retail's real behavior. Allegiance's unported subcommands correctly stay local (never reach ACE) but show a generic refusal instead of retail's real per-subcommand execution — a user typing e.g. `@allegiance boot Name` gets "Please see @help Allegiance..." instead of retail's real boot confirmation/effect, until #360 ports the nine `DoAllegiance*`/`DoMotd`/`DoAllegianceHouse` handlers. | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` opcodes for each subcommand (all exist server-side) | -| TS-69 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@day`, `@log`, and `@render` are registered retail verbs acdream recognizes only in the `/help ` lookup table, not as executable client commands. `@day` needs a sky/time-of-day override hook the renderer doesn't expose; `@log` needs a safely-lifecycled chat-to-file writer (deferred to avoid an unaudited file-handle leak across reconnects); `@render` has no acdream equivalent to retail's `SmartBox::HandleRenderOption` render-option surface. All three fall through to server passthrough. `RetailCommandHelpTable` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs`) | A user typing `@day`/`@log`/`@render` gets ACE's "Unknown command" instead of retail's local toggle/file-copy/render-option behavior — cosmetic/QoL only, no gameplay impact | `ClientCommunicationSystem::DoDay @ 0x005706F0`; `DoSetOutput @ 0x0057E4F0`; `DoRenderOption @ 0x0057E120` | +| ~~TS-68~~ | **RETIRED 2026-08-28 (#360).** The full named-retail `@allegiance`/`@all`, `@house`/`@hou`, and standalone `@motd` grammar now executes locally through one Runtime dispatcher shared by graphical and headless hosts. All management branches have byte-verified GameAction builders, exact argument/refusal behavior, client-local unknown-subcommand ownership, shared sequence allocation, and active-session routing. | retired | — | — | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` readers; docs/ISSUES.md #360 | +| ~~TS-69~~ | **RETIRED 2026-08-28 (#361).** `@day`, `@log`, and `@render` now execute locally: persistent noon landscape lighting, reconnect-safe chat-file lifecycle, and named-retail radius/FOV parsing and replies. | retired | — | — | `ClientCommunicationSystem::DoDay @ 0x005706F0`; `DoSetOutput @ 0x0057E4F0`; `DoRenderOption @ 0x0057E120`; docs/ISSUES.md #361 | | TS-67 | **Ambient contributions are computed in-plane.** Retail's `CLandBlock::add_ambient_sounds` @ `0x530310` positions each contributing land cell at its own SW terrain VERTEX, including that vertex's height, and `Ambient::CalcWeight` deliberately includes Z in its distance (where `CalcDir` deliberately excludes it — the two differ on purpose). acdream's gatherer supplies Z = 0 for the offset, so a cell's weight ignores the height difference between the listener and the terrain under that cell. | `src/AcDream.Core/Audio/AmbientSoundGatherer.cs` (`ContributeLandblock`) | Sampling the height needs the landblock's height table threaded into the walk alongside the terrain words; the walk already runs only on a 24 m crossing so the cost is not the obstacle, the extra plumbing at slice end was. The error is bounded by terrain relief inside 120 m and affects the crossfade weight only, never the direction. | On steep ground an ambient reads slightly louder than retail, because the true 3-D distance is longer than the planar one. | `CLandBlock::add_ambient_sounds @ 0x530310`; `Ambient::CalcWeight @ 0x550DD0` | | TS-74 | **Filed 2026-08-11 at Campaign OP slice OP3; What/Where extended 2026-08-11 at the OP3 review-fix round (mechanism review S5).** acdream has no persistent "turn to face camera" mouse-turning MODE — `MouseLookState` only implements retail's MMB-hold `CameraInstantMouseLook`. The Options panel's "Use Mouse Turning Settings" button still sends the `PlayerOption.UseMouseTurning` bit (`SetSingleCharacterOption 0x0005`) and persists the five client-local `CameraTurningSettings` preferences exactly as retail does — but flipping the bit ON has NO observable effect on acdream's camera today, because the mode it is supposed to enable was never built. **All five persisted preferences are STORE-ONLY with no consumer, not just the camera mode itself:** `Camera_Stiffness`, `Camera_AdjustmentSpeed`, `Camera_AlignToSlope`, `Input_MouseLookSensitivity`, and `Input_InvertMouseLookYAxis` (research doc `2026-08-10-options-panel-structure.md` §4) land in `settings.json`'s `cameraTurning` section and are read back only by the macro itself — acdream's ACTUALLY-live mouse sensitivity lives entirely separately, in `CameraPointerInputController`'s `_chase`/`_flySensitivity`/`_orbitSensitivity` fields (F8/F9-adjustable), so the macro's chat lines quote a `Default`-seeded "from" value (e.g. `0.550000`) that describes no live client state on a fresh profile. **LANDED 2026-08-11 at Campaign OP slice OP6**: the Config tab now surfaces all five as its own Camera/Input rows (`ConfigOptionsPageController.BindCameraSection`/`BindInputSection`), plus a SIXTH, previously-unmodeled field — `CameraTurningSettings.UseMouseTurning` (`Input_UseMouseTurning`, the Config tab's OWN client-local checkbox, distinct from the server-synced `PlayerOption.UseMouseTurning` bit this row already describes) — with the SAME store-only disposition; the "two stores for one concept" symptom below is now directly observable rather than latent. | `src/AcDream.UI.Abstractions/Input/MouseLookState.cs` (the only mouse-look mode present); `src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs` (sends the bit regardless); `src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs` (the six store-only keys); `src/AcDream.App/Input/CameraPointerInputController.cs` (the SEPARATE, actually-live sensitivity fields); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (the Config tab's own rows, OP6) | Building the persistent mouse-turning camera mode is a camera/physics-scope feature, out of the Options-panel campaign's scope; the STORE-and-SEND half is honest and complete (matches every other stored-but-unconsumed option class in this register), so the bit round-trips correctly for any future consumer or a retail client reading the same character. | A user who clicks "Use Mouse Turning Settings" expecting the camera to start turning with mouse movement sees no camera change — only the (unwired) preferences persisting and the wire bit flipping. Beyond that: a user who separately tunes acdream's live F8/F9 mouse sensitivity, then clicks this button, sees a chat line quoting an UNRELATED stored value, not their live sensitivity — two stores for one concept, now user-visible in the Config tab UI (OP6). | `PlayerModule::UseMouseTurning @0x005D3380`; `CharacterOptions2.UseMouseTurning 0x00400000`; `claude-memory/project_camera_visibility_coupling.md` | diff --git a/docs/launch-options.md b/docs/launch-options.md index 0d6178ec..9decd203 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -40,11 +40,13 @@ Assume a flag has a side effect until its row says otherwise. - **Everything diagnostic is OFF by default.** Every probe, dump, capture, and measurement flag in this document is inert until its variable is explicitly set — an unset environment runs zero diagnostics. Exactly - four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, + five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B - off-switch (`=0` disables the behavior for a comparison run). That - four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new + off-switch (`=0` disables the behavior for a comparison run), while + `ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses + the same explicit diagnostic opt-out. That five-flag set is frozen by + `LaunchOptionsDocumentationTests` — a new default-on flag fails the build. - `=1` means the code tests for exactly the string `1`. Setting `true`, `yes`, or `0` does **not** enable such a flag (and `0` does not disable @@ -75,14 +77,13 @@ $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" -$env:ACDREAM_RETAIL_UI = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release ``` | Flag | Value | What it does | Side effects | Default | Read by | |---|---|---|---|---|---| | `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) | -| `ACDREAM_AC_DIR` | `=` | Points at a real retail AC install dir; loads `/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir` → `InteractionRetainedUiComposition.cs:610` | +| `ACDREAM_AC_DIR` | `=` | Points at a real retail AC install dir; loads `/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect while the default-on retained UI is composed (`ACDREAM_RETAIL_UI` is not `0`). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir` → `InteractionRetainedUiComposition.cs:610` | | `ACDREAM_ANISOTROPIC` | `=` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) | | `ACDREAM_CACHE_DIR` | `=` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam | | `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` | @@ -115,7 +116,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release | `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) | | `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` | | `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades` → `DatLiveEntityProjectionMaterializer.cs:275-276,480-498` | -| `ACDREAM_RETAIL_UI` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` | +| `ACDREAM_RETAIL_UI` | `=0` disables; anything else/unset enables | Controls the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b), the product's only gameplay UI. | Disabling it leaves world rendering with no gameplay interface and is intended only for diagnostics. The same rule applies to bare env and `--session-config`/launcher launches; no path force-overrides it. | `true` | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs` (gates retained-UI mount via `InteractionRetainedUiComposition`) | | `ACDREAM_TEST_HOST` | `=` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) | | `ACDREAM_TEST_PASS` | `=` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) | | `ACDREAM_TEST_PORT` | `=` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) | @@ -184,7 +185,7 @@ fall back to `ACDREAM_DAT_DIR`. | `ACDREAM_ORBIT_YAW_DEGREES` | `=`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` | | `ACDREAM_PROBE_REVEAL_RADIUS` | `==1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | | `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | -| `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | +| `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of monotonic real elapsed time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → monotonic elapsed time (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.AnimationPhaseSecondsOverride` (cloud/rain UV scroll) **and** `AtmosphericPostProcessGraph`'s foliage-wind clock | | `ACDREAM_STREAM_WORK_COMPLETIONS` | `=` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) | | `ACDREAM_STREAM_WORK_CPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) | | `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) | diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 6947bfae..903f5875 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -503,6 +503,12 @@ elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value. **Unset — the default, and every ordinary run — keeps the wall clock**, so nothing the user or the offline gate sees changes unless a gate asks. +**2026-08-28 follow-up:** the ordinary sky-animation source is now a monotonic +`Stopwatch` clock, matching retail's accumulated timer deltas and preventing an +OS time correction from jumping rain/cloud UVs. The V7 pin and its gate +semantics are unchanged; the wall-clock wording above describes the original +V7 implementation. + It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override pin only the *other* sky clock: the Dereth date, which chooses the day group, the keyframe and the sun angle. The cloud sheet does not read that clock at all @@ -2591,6 +2597,10 @@ Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was `-MaskTopPixels`, which would have permanently blinded the campaign's strictest instrument to the entire sky.** See §5.1. +The 2026-08-28 rain-timing parity follow-up replaced that adjustable wall clock +with monotonic `Stopwatch` elapsed time; this historical V7 measurement and the +diagnostic pin remain otherwise unchanged. + **3. The world clock was never pinned at all, and that was most of the number** (`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times. The mechanism underneath is `WorldTimeService.SetDebugTime`, and diff --git a/docs/research/2026-08-09-chat-retail-command-registry.md b/docs/research/2026-08-09-chat-retail-command-registry.md index 84d2450a..19d838e5 100644 --- a/docs/research/2026-08-09-chat-retail-command-registry.md +++ b/docs/research/2026-08-09-chat-retail-command-registry.md @@ -13,6 +13,12 @@ > channel ids) remains accurate and is still the authority. Only the columns > describing what acdream does are out of date; verify against > `ChatInputParser.cs` before trusting them. +> +> **CORRECTION 2026-08-28 — issue #360 is complete.** The full local +> `@allegiance`/`@all`, `@house`/`@hou`, and standalone `@motd` dispatcher +> families are implemented with the named-retail grammar and exact GameAction +> wire layouts in both graphical and headless hosts. The older MISSING status +> cells in §2.5/§2.5b and the priority list are historical. Date: 2026-08-09 diff --git a/docs/research/2026-08-17-map-house-recon.md b/docs/research/2026-08-17-map-house-recon.md index 008dfca3..81c007e5 100644 --- a/docs/research/2026-08-17-map-house-recon.md +++ b/docs/research/2026-08-17-map-house-recon.md @@ -199,6 +199,27 @@ parsed at `CreateObject.cs:611-619`) — reused rather than re-defined. `RuntimeHouseState` raw-field owner), and file the seven line builders as an ISSUES entry rather than porting them tonight. +### 2026-08-28 owned-house closeout + +Issue #413 completed this deliberately deferred builder chain. Direct byte +disassembly of the PDB-paired `acclient.exe` recovered the literals and the +x87-obscured branches: + +- buy and rent rows use `HousePaymentList::ComposeText`/`ComposeText2` with + retail's singular/plural fallback (`s`, or `es` after lowercase `s`/`x`); +- normal houses use 2,592,000-second periods and apartments use 7,776,000; + paid/maintenance-free rent advances the next-due row by two periods; +- location is `Location: %.1f%s, %.1f%s`, Y/S-N first and X/W-E second; + apartments emit no location; +- paid and unpaid warnings select `HousePanelTextColor` indices 1 and 2 + through the row template's authored font-color palette; +- 0x0227 installs the new rent time and clears paid counts, while 0x0228 + replaces the rent list; both rebuild the whole display just as retail does. + +`RuntimeHouseStateTests`, `LiveSessionEventRouterTests`, and +`MapHousePanelControllerTests` cover the full synthetic wire/state/UI path. +The canonical Release gate passes 16,315/16,315 on 2026-08-28. + ## Wire — GameEventType already has all four ids (corrects the handoff) The handoff claimed "0x0227/0x0228 absent from the enum". Checked diff --git a/docs/research/2026-08-28-issue178-cell-shell-culling.md b/docs/research/2026-08-28-issue178-cell-shell-culling.md new file mode 100644 index 00000000..bc205435 --- /dev/null +++ b/docs/research/2026-08-28-issue178-cell-shell-culling.md @@ -0,0 +1,68 @@ +# Issue #178 — retail EnvCell shell culling (2026-08-28) + +## Outcome + +The Phase A8 `CullMode.Landblock -> CullMode.None` workaround is removed +from both production EnvCell draw paths. Constructed cell-shell batches now +use retail's clockwise cull state instead of drawing every ordinary shell +face twice. No PAK rebuild is required: the package already contains the +authored `sides_type` and the correctly expanded index geometry; this change +selects the correct GPU state when that geometry is drawn. + +The source and installed-DAT gates are complete. The owner visual gate passed +2026-08-28 (“Ok looks good”) at the requested interior matrix; #178 is closed. + +## Retail oracle + +The misleading detail is that `DatReaderWriter.Enums.CullMode` is used for +the CellStruct polygon's `sides_type`; its values are not direct GPU cull +states. + +- `D3DPolyRender::ConstructMesh @ 0x0059DFA0` expands `sides_type` 0 as the + positive face, type 1 as that face plus a reversed copy, and type 2 as the + positive and negative surfaces. Its polygon fan is `[0, i-1, i]`; the + reversed copy is `[i, i-1, 0]`. +- `D3DPolyRender::RenderMeshSubset @ 0x0059CA10` draws the constructed mesh + with `D3DCULL_CW` on the ordinary path. +- `RenderDeviceD3D::DrawEnvCell @ 0x0059F170` uses that constructed-mesh + route. The immediate-mode exception for a type-1 polygon does not apply to + the EnvCell mesh. + +`MeshExtractor.PrepareCellStructMeshData` already reproduces retail's fan and +the required reversed geometry. The render policy therefore must be +clockwise culling for every constructed shell batch; mapping DAT value 0 to +`None` was the obsolete workaround, while mapping it to the generic +`Landblock` render state would cull the opposite side. + +## Installed-DAT catalog + +`A8CellAudit cell-winding-catalog` scanned the installed DATs: + +- 772 environments and 3,168 CellStructs +- 38,189 polygons and 70,091 generated fan triangles +- 37,843 `Landblock(0)` polygons and 346 `None(1)` polygons +- no unknown or unsupported `sides_type` values +- no missing polygon vertices + +Vertex-normal orientation was recorded as a diagnostic, not treated as a +contract: CellStruct vertex normals may be smoothed rather than geometric, +and retail submits the identical authored fan. + +## Implementation and gates + +- `EnvCellRenderer.ResolveRetailCellShellCullMode` documents and enforces the + constructed-mesh policy in both the main and shadow-receiver draw paths. +- Hermetic extraction tests pin the exact type-0 fan and type-1 reversed-face + expansion. +- Renderer tests pin all four source enum values to the retail clockwise + constructed-mesh state. +- Installed-DAT audit: passed. +- Canonical Release gate: 16,321 passed, 0 skipped, 0 failed across 14 test + assemblies; Release build completed with 0 warnings and 0 errors. + +## Owner visual acceptance + +In Holtburg buildings and the Facility Hub, rotate the camera through walls, +floors, ceilings, ramps, and stairs from their ordinary playable sides. +Nothing should vanish at any camera angle. Acceptance of that matrix closes +#178. The owner accepted this matrix on 2026-08-28 (“Ok looks good”). diff --git a/docs/research/2026-08-28-open-issue-validity-audit.md b/docs/research/2026-08-28-open-issue-validity-audit.md new file mode 100644 index 00000000..4957f732 --- /dev/null +++ b/docs/research/2026-08-28-open-issue-validity-audit.md @@ -0,0 +1,141 @@ +# Open-issue validity audit — 2026-08-28 + +## Scope and verdicts + +This is a read-only product audit of every non-final entry in +`docs/ISSUES.md`. It does not fix product code and it does not close or +reprioritize anything. The ledger contains **88** such entries, not 87: +`#341` still says `OPEN` on its status line even though its heading and its +own evidence say it closed with a 10/10 gate. + +- **CONFIRMED CURRENT** means the current source, a current missing path, or + repeated recorded evidence supports the material claim. This bucket also + identifies whether the item is a product defect, missing feature, + maintenance/performance debt, or test-infrastructure flake. +- **GHOST / NOT A CURRENT CLIENT ISSUE** means the entry is fixed, stale, + superseded, based on a deleted path, an accepted decision, an external + system/environment condition, or is a research/test-plan note misfiled as a + client issue. “Ghost” does not mean the original report was fabricated. +- **NEEDS VERIFICATION** means there was plausible historical evidence, but + current source inspection cannot prove the user-visible symptom still + exists. These should not be called either fixed or real until a focused + current-binary gate reproduces or clears them. + +The audit checked the current source and tests, the evidence/status text in +the ledger, superseding issue/campaign records, and a clean current Release +build. The build completed with **0 warnings and 0 errors**. + +## Confirmed current — 35 + +| ID | Type | Verification basis | +|---:|---|---| +| #178 | Closed after audit (owner-accepted 2026-08-28) | Named-retail confirms CellStruct `sides_type` controls constructed geometry and every constructed EnvCell subset uses clockwise culling. Both double-sided overrides are removed, the installed-DAT catalog found no invalid side types or missing vertices across 38,189 polygons, winding/policy tests plus the 16,321-test Release gate passed, and the owner accepted the Holtburg/Facility-Hub visual matrix. | +| #241 | Fixed after audit (2026-08-28) | The always-on parity partition now rejects off-frustum landblocks before walking entities and preserves the player's current landblock, using the prepared frame frustum already available at the call site. | +| #258 | Owner-voided 2026-08-28 | The former ImGui host is deleted, but the owner classified a replacement developer-panel host as undesired scope and closed the entry. | +| #261 | Telemetry defect | Production `LinkStatusSnapshot` still receives no packet-loss calculation, so `PacketLossPercentage` remains its default. | +| #310 | Fixed after audit (2026-08-28) | Collision-prefix retirement now supersedes an exact authored mover still awaiting first preparation, then completes through the normal withdrawal handshake; the former indefinite-poll regression converges. | +| #311 | Fixed after audit (2026-08-28) | `RetryPendingProjections` now uses retained, depth-safe scratch lists; the warmed fixture drops from 424 B to the event stream's 72 B publication floor and re-entry is regression-tested. | +| #313 | Fixed after audit (2026-08-28) | Successful split-to-world recovery now selects the resulting GUID through the canonical `SelectionState`; unrelated unknown spawns cannot steal selection. | +| #316 | Fixed after audit (2026-08-28) | The player-only `AirborneSnap` shadow skip is gone; every accepted landing publishes the resolved collision pose and the 27-row routing matrix passes. | +| #320 | Fixed after audit (2026-08-28; stale ledger entry) | The Runtime physics cutover already committed every ordinary transition's exact cell and rebucketed from `CellCommitted`; the added local-player test proves source-landblock retirement cannot park a player who walked across the boundary. | +| #322 | Fixed after audit (2026-08-28) | Both production callers now consume one pure disposition/HasAnims pre-placement derivation; the end-to-end application matrix and explicit truth table pass. | +| #324 | Architecture debt | Graphical and no-window hosts still own parallel inbound entity-routing composition. Accurate structural debt; no current symptom is proven. | +| #325 | Fixed after audit (2026-08-28) | Gate A and Runtime authority now accept equal-or-newer teleport stamps without consuming TELEPORT_TS; wrap, stale-pair, velocity, hook, and acknowledgement tests pass. | +| #330 | Headless capability defect | The headless composition still registers no live-entity collision owner, so bots cannot collide with dynamic creatures/objects. | +| #332 | Headless capability gap | Headless composition still lacks the graphical host's remote dead-reckoning path. Whether this is required product scope remains a decision. | +| #340 | Fixed before audit; stale ledger entry | `dfc841b7` injected a deterministic meter clock and the fixture uses a constant timestamp, removing suite load from the policy contract. | +| #346 | Fixed before audit; stale ledger entry | `dfc841b7` added tiered-JIT warmup plus five-batch sampling while retaining a threshold far below the former linear allocation regression. | +| #359 | Fixed after audit (2026-08-28) | The live route now passes the canonical player GUID and `ChatLog.OnPlayerKilled` suppresses victim/killer recipients exactly like retail; regression tests cover victim, killer, and bystander. | +| #360 | Fixed after audit (2026-08-28) | One shared Runtime dispatcher now implements the complete named-retail allegiance, house, and standalone `@motd` grammar for graphical and headless hosts; all GameAction payloads are byte-verified and the 16,309-test Release gate passes. | +| #361 | Fixed after audit (2026-08-28) | `@log` has a reconnect-safe file lifecycle; `@day` now toggles persistent noon landscape lighting; `@render radius/fov` implements the named-retail parsing, bounds, replies, and persisted renderer settings. | +| #370 | Headless movement defect | The released-jump probe reproduced 3/3 after the threading hypothesis was eliminated. No later fix is recorded. | +| #393 | Void/closed after audit (2026-08-28) | High-resolution DAT use is already implemented in both live and pak paths. The alleged separate retail toggle was a research misread; the remaining old texture-level degradation is explicitly unwanted by owner direction. | +| #400 | Fixed after audit (2026-08-28) | The exact authored two-root `gmCreditsUI` flow is mounted: 2,345 localized fragments, seven cyclic pictures, retail timing/shared scroll, Please Wait exit, and return to character management. Installed-DAT and controller gates pass. | +| #401 | Fixed after audit (2026-08-28) | Retained UI is now default-on across all launch paths, literal `ACDREAM_RETAIL_UI=0` opts out, the session-config force is removed, and authoritative launch docs/tests are updated. | +| #402 | Fixed before audit; stale ledger entry | `dfc841b7` moved the contract to a named dedicated thread with bounded start/block/join and captured worker failure, eliminating the fragile scheduling observation. | +| #403 | Fixed after audit (2026-08-28) | The live presenter now delegates advance/wrap and no-sequence interpolation to `RetailAnimationCyclePlayback`; Core and presenter regression suites pass. | +| #404 | Fixed after audit (2026-08-28) | Resolver now consumes `Runtime.CharacterCreation.Options`; the second raw SkillTable read and lock are gone, with focused, synthetic-projection, and installed-DAT gates passing. | +| #408 | Fixed after audit (2026-08-28) | The shared importer now applies DAT `0x3B` client-wide; all stateful retained widget types apply named/DirectState visibility, the scoped chargen path is deleted, and an installed-DAT sweep proves 990/990 built authored-invisible widgets across 38 layouts start hidden. | +| #410 | Fixed after audit (2026-08-28) | Both justification axes now share retail's exact 1/3/5 table, unauthored text defaults Left/Top, merge uses property presence, and automated plus installed-DAT UI suites pass. | +| #413 | Fixed after audit (2026-08-28) | All owned-house builders now match the recovered retail strings/math/colors; 0x0227/0x0228 refresh the shared Runtime owner, synthetic wire/state/UI coverage passes, and the 16,315-test Release gate is green. | +| #421 | Performance debt | The directional-shadow renderer still owns and uploads `DirectionalShadowTransformBufferSet` separately from the main instance SSBO. | +| #422 | Intermittent native crash | Heap corruption at graceful exit has multiple independent sightings, including a connected run. It lacks a stack, not evidence. | +| #423 | Rendering-policy mismatch | The atmospheric pack still declares and evaluates raw `ActiveDayGroupMultiplier` values instead of a `WeatherKind`-keyed policy. | +| #428 | Fixed after audit (2026-08-28) | Installed DATs proved the scripted sky carriers have one identity-transformed part 0; publishing that exact synthetic part lets legitimate particle hooks resolve, while the day-group-flip regression proves teardown prevents later dispatch. | +| #438 | Missing diagnostic feature | Launcher crash bundles are genuinely not implemented. This is an approved enhancement, not an existing launcher malfunction. | +| #442 | Fixed before audit; stale ledger entry | `d123c4b6` moved the warmed dense path to the shared 64-frame zero-allocation measurement probe; the strict zero allocation contract remains. | + +## Ghost / not a current client issue — 33 + +| ID | Why it should not remain framed as a current client defect | +|---:|---| +| #3 | Fixed: periodic `TimeSync` is parsed and routed into the world clock. | +| #73 | A process policy for future string sweeps, not a concrete defect; its own text says no infrastructure work remains. | +| #180 | Both camera-collision fixes shipped and were log-verified; the residual visual was moved to now-closed #181. | +| #194 | Fixed: `WbDrawDispatcher.BeginFrame` prunes old instance groups and has coverage. | +| #195 | Obsolete architecture: the duplicate ChatVM/provider shape described by the issue no longer exists; local commands have dedicated routing. | +| #199 | Fixed by the Campaign CA server-authoritative one-request-in-flight raise flow; optimistic local mutation was removed. | +| #200 | Stale migration list: the old inline mounts/MockupDesktop path named by the issue no longer describes current composition. | +| #212 | Implementation and regression coverage are present; the status remained `IN-PROGRESS` only for an old user gate. | +| #213 | Fixed: client commands are intercepted by `ClientCommandController` instead of being sent to ACE as chat. | +| #228 | Directly disproved by the current Release build: 0 warnings, not 17. | +| #242 | Fixed: static presentation orders/prepares snapshots once and reuses the prepared replacement rather than rebuilding a third dictionary per attempt. | +| #249 | Obsolete: it targets the deleted OpenGL/bindless backend; production is Vulkan-only. | +| #251 | Obsolete: it targets deleted `glClientWaitSync`/OpenGL fence code. | +| #256 | Superseded by #260; its discriminator found no missing-object drift and the transport-loss mechanism was fixed elsewhere. | +| #257 | Superseded/refuted as a leak: its own portal-churn discriminator was flat/negative and later PAK/runtime work replaced the measured architecture. | +| #259 | Explicitly a machine-wide Vulkan/environment failure, not an acdream product defect. | +| #274 | A request for a connected retail comparison, not an observed failure. It belongs in a gate/research checklist. | +| #309 | Explicit owner-accepted divergence recorded in the divergence register, with no planned fix. | +| #318 | A test-plan/composition-coverage residual, not evidence of a product defect. Track as test debt if still desired. | +| #339 | Fixed and live-validated; current mesh-publication guards are present. The status header was never finalized. | +| #341 | Internally contradictory ghost: heading/evidence say closed and 10/10 bit-identical, but the status line still says `OPEN`. | +| #342 | Fixed: the current assertion compares old-model and new-model values rather than the same expression to itself. | +| #343 | Fixed; the issue body already records the guarded native-release lifecycle correction. | +| #344 | Fixed; the issue body already records the teleport-authority discriminator and clean suite. | +| #350 | Fixed in current source: the lifetime render-shadow counters are widened to `long`. | +| #352 | The behavior was live-gate verified; the only remaining request is one extra discriminating unit test. This is test debt, not a vendor bug. | +| #366 | Fixed: the chat controller now owns the unread indicator, unseen-text state, tick/click behavior, and tests. | +| #369 | An unanswered retail research question with no demonstrated mismatch, so it does not belong as a product issue. | +| #383 | Installed-DAT/committed-fixture provenance drift is environment/fixture-maintenance work, not a current client defect. | +| #384 | The client sends the swear action; the missing response is an ACE server behavior/blocker, not an acdream client bug. | +| #396 | Fixed and live-verified crash-free; only a visual re-check of the already-mounted instruction dialog remained. | +| #425 | Fixed; both resolution-scaled pack budgets and activation memo behavior are recorded in the issue itself. | +| #427 | Fixed and owner-reported; the sky/fog seam correction is already recorded in the issue itself. | + +## Needs current verification — 20 + +| ID | What is known and what is still required | +|---:|---| +| #2 | The old lightning presentation mismatch was plausible, but the sky/PES pipeline changed substantially. Reproduce side-by-side on the current binary. | +| #29 | The thin-cloud observation has no current post-renderer visual gate. A present-day retail comparison is required. | +| #55 | The 1.45M `meshMissing` figure belongs to an old streaming diagnostic. Re-measure current PAK-v2 production before treating it as real. | +| #130 | Closed as owner-accepted residual (2026-08-28) | The connected visual re-gate confirmed the thin top-edge strip remains; owner direction is to leave it as-is. | +| #177 | Strong historical dungeon evidence exists, but the renderer/portal pipeline changed afterward. Repeat the named stair routes on the current binary. | +| #183 | Owner-closed 2026-08-28 after the validity audit. | +| #250 | The zero-allocation flakes were real historically, but the stated roughly-one-in-three frequency predates major runtime/test changes. Run a current parallel stress lane. | +| #262 | Owner reports the first-login movement issue solved; closed 2026-08-28. | +| #265 | Closed during follow-through: Campaign P's final user matrix already accepted downhill bounce, flat pop, and uphill landing on 2026-07-31. | +| #267 | Closed during follow-through: Campaign P's final user matrix already accepted live vitae/buff values and immediate skill-row refresh on 2026-07-31. | +| #317 | The call still exists, but “has no retail basis” is a research conclusion, not proof that current behavior is wrong or reachable. | +| #321 | One old full-suite sound-cache failure is not enough to establish a current flake after later suite/runtime changes. Stress the exact class in parallel. | +| #323 | The stale-receipt mechanism is plausible, but production reachability and a user-visible symptom were never established. | +| #377 | The fullscreen crash was once deterministic but is explicitly not reproducible on current display code. A current cold-start matrix decides it. | +| #397 | Closed during follow-through: the real Windows supervisor stopped a connected Release headless host gracefully, and a separately supervised process re-entered the same account after the documented 2.5-second ACE account-release quiescence; both exited code 0. | +| #431 | CA2–CA4 implemented the missing inbound/recompute flow and the first CA5 drive passed Quickness/run updates. The original title is no longer proven; remaining CA5 cases need live gating. | +| #433 | Owner-observed stale entities are credible, but the issue is intermittent and has no current captured reproduction. | +| #439 | One full-suite failure followed by an isolated pass and clean rerun is only a flake candidate. Run the established timing lane before confirming it. | +| #441 | Owner-closed 2026-08-28 after two old observations were followed by a healthy probed baseline and no recurrence. | +| #452 | Closed during follow-through: both fixed `app-release24` sessions have retained logs proving graceful logout and orderly teardown after the 100-switch/30-minute stress. | + +## Recommended ledger cleanup order + +1. Close or archive the 33 ghost entries after owner review. +2. Keep type labels on confirmed items so + enhancements, maintenance debt, and flaky tests are not mistaken for + gameplay defects. +3. Run one focused verification batch for the 20 uncertain entries. Close a + fixed-awaiting-gate item when its current gate passes; close an old symptom + as stale when its documented current reproduction route no longer fails. +4. Prioritize only the confirmed product defects after the ledger is clean; + do not mix them with refactors, test debt, or external ACE/environment work. diff --git a/docs/research/2026-08-28-owner-closed-issue-validity-audit.md b/docs/research/2026-08-28-owner-closed-issue-validity-audit.md new file mode 100644 index 00000000..a41a995d --- /dev/null +++ b/docs/research/2026-08-28-owner-closed-issue-validity-audit.md @@ -0,0 +1,108 @@ +# Owner-closed issue validity audit — 2026-08-28 + +## Scope and verdicts + +This is a read-only companion to the +[open-entry validity audit](2026-08-28-open-issue-validity-audit.md). It checks +the exact **57 unique issue IDs** in the owner's 2026-08-28 closure request. +Fifty-six entries were newly marked closed; `#167` was already `DONE` before +that request. This audit changes no issue status and fixes no product code. + +- **OWNER-CONFIRMED CLOSED DESPITE REMAINING EVIDENCE** means current source or + still-applicable recorded evidence supports unresolved work, but the owner + explicitly chose on 2026-08-28 to keep the entry closed. This records a + scope/status decision, not technical proof that the symptom is fixed. +- **VERIFY BEFORE KEEPING CLOSED** means the historical report was credible, + but later architectural changes or an implemented fix make a current live + gate necessary before deciding whether the closure is correct. +- **SAFE TO REMAIN CLOSED** means current code/tests and later project history + show the issue fixed, superseded, disproved, owner-accepted, or not a valid + product defect in the first place. + +The audit inspected current source and focused tests, each issue's complete +evidence trail, later campaign/user-gate records, and the current architecture. +The supporting focused Release tests completed with **321 passed, 1 skipped, +0 failed**. The companion open-entry audit's clean Release build completed with +**0 warnings and 0 errors**. + +## Owner-confirmed closed despite remaining evidence — 3 + +| ID | Remaining technical evidence | +|---:|---| +| #64 | The local pickup transaction sends/accepts the pickup, but the current local-player animation path still has no pickup one-shot trigger and no admitted server-motion equivalent. The original missing local bend/grab animation remains an unimplemented path. | +| #235 | The live animation presenter still publishes part poses at admitted object-clock frames. Root presentation is smoothed, but no render-time interpolation exists between animated part poses, which is the exact capped/RDP cadence alias described by the issue. | +| #263 | The Drudge Scrying Orb residual was user-confirmed after the general translucency fix and explicitly deferred. No later orb-specific ClipMap/order fix or clearing visual gate exists. | + +## Verify before keeping closed — 11 + +| ID | What is known and what should be gated | +|---:|---| +| #41 | The remote-root-motion correction was implemented, but the entry never received its final two-client sub-decimeter-blip visual gate. Observe another player starting/stopping/turning on the current binary. | +| #94 | The original held-light report became testable only after child parenting landed. Current lighting distinguishes authored point and spot lights, but there is no post-parenting side-by-side reproduction or clearance of the wall spotlight symptom. | +| #115 | The cramped-interior camera feel report was credible, but camera collision, viewer stepping, display pacing, and the renderer changed substantially afterward. Repeat the same pressed-wall turns against retail. | +| #129 | The long-distance door/doorway leak fix shipped, but the entry retained an explicit distant-view visual gate. Revisit the named over-one-landblock views before treating it as conclusively closed. | +| #165 | Remote wall penetration was observed, while the leading physics explanations were later ruled out and the remaining presentation hypothesis was never proven. A current observer/client reproduction decides it. | +| #181 | The Facility Hub artifact survived its original seven-fix ladder, but its early attribution was partly retracted and the camera/render pipeline later changed. Repeat the exact pressed-camera pose without capture overlays. | +| #208 | The peaceful-login combat-bar correction is present, but the issue's final fresh-login visual check was never recorded. Gate a fresh peaceful login and one relog. | +| #225 | The particle/translucent-object ordering implementation and stress tests pass, but the issue explicitly retained its final lifestone/candle visual matrix. Run that matrix; `#263` remains a separate orb-specific residual. | +| #337 | The Neftet rock behavior is covered by the `#333` broadphase fix, but the exact rock-plateau live acceptance was never recorded. Re-test that fixture before relying only on the general mechanism. | +| #409 | The client-wide tooltip system is code-complete and one live path was proven, but the remaining connected hover paths in its acceptance matrix were still owed. Complete that small UI gate. | +| #418 | The original 27 s/32-blocks-per-second limiter was fixed and reduced dramatically, so the title is stale. Cold-login readiness was still above the retail-feel target at the last measured checkpoint, and later PAK/runtime work was not re-measured with the same probe; run one current cold-login timing gate. | + +## Safe to remain closed — 43 + +| ID | Why the closure is safe | +|---:|---| +| #102 | Fixed: `PortalVisibilityBuilder` now uses retail's in-place `update_count` watermark/fixpoint propagation; the old per-cell cap is gone and focused convergence tests pass. | +| #104 | Fixed: VFX instances carry owner-cell identity and `ParticleVisibilityController` gates them against the current PView visible-cell set. | +| #114 | Superseded by the current indoor renderer: the old `gl_ClipDistance` shell-chop mechanism named by the issue was deliberately deleted in favor of whole-shell depth/order handling. | +| #116 | The second divergence was fixed; the remaining first shape was traced to a simplified fixture while the real Path-5/Path-6 behavior is retail-exact. No confirmed engine defect remains. | +| #120 | Fixed: the reciprocal/in-place convergence case is pinned by the current portal-visibility replay tests. | +| #121 | Fixed and superseded by later portal campaigns/gates; world-portal VFX are routed through the current scene-particle path rather than the broken visibility path described here. | +| #139 | Fixed: retained buttons and menus now implement the authored normal, rollover, pressed, highlight, and open-state transitions with regression coverage. | +| #144 | Not an established defect: the empty-slot release is a harmless no-op and the entry never established that retail suppresses the click notification differently. | +| #146 | Non-actionable polish list: the capacity-bar visuals were already accepted and the entry asks for unspecified additional polish rather than describing a remaining defect. | +| #148 | Fixed: the authored backpack button toggles the inventory panel and projects open/closed state back to Highlight/Normal, with tests. | +| #151 | Fixed for the reported defect: the Arwic/city wall collision was user-verified. The residual terrain note was a question, not a demonstrated mismatch. | +| #158 | Not a bug by its own description; it is an old, vague character-window polish list against presentation paths that were subsequently replaced. | +| #166 | Fixed and user-gated as part of the closed Campaign P landing-momentum/bounce family. | +| #167 | Already `DONE` before the cleanup request; the retail leash behavior and constants were ported and covered. | +| #172 | Fixed and covered by the user-accepted Campaign P portal-platform/step-up gate. | +| #173 | Fixed and covered by the user-accepted Campaign P remote ceiling-response gate. | +| #174 | Fixed: door-use animation-link queue handling shipped and the later physics/door gate was accepted. | +| #175 | Fixed: door collision now follows the closed motion-table pose; the later physics/door gate was accepted. | +| #176 | The original purple flashing/lighting defect was fixed and verified. Its distinct pressed-camera residual was split into `#181`, so keeping this parent closed is correct. | +| #182 | Fixed by the retail collision-velocity rebuild and covered by the accepted crowded-movement Campaign P matrix. | +| #191 | Fixed by the root-motion presentation path; current coverage asserts that a brief forward edge cannot glide when the animation contributes no root delta. | +| #193 | Fixed and measurement-verified: the retained-resource/OOM correction survived the extended-play resource gates. | +| #202 | Fixed: the current `WeenieErrorMessages` table and routing replace the old missing lookup and cover interpolation plus `UseDone` behavior. | +| #209 | Fixed and subsequently live-gated: the jump/combat power-bar modes and captions were accepted in the later keyboard/powerbar rounds. | +| #226 | Fixed and connected-visual verified: building/EnvCell detail-texture overlay is present in the modern renderer. | +| #253 | Superseded/disproved by later byte-exact retail-DAT research: the shared row template authors a flush-left 20×20 icon cell, and the current sprite draw preserves authored texture alpha. The earlier guessed centering offset was not retail ground truth. | +| #264 | Research note, not a confirmed client defect: it explicitly records no known `WATER_CONTACT_TS` divergence and asks for xref/swim investigation. Any discovered mismatch should be filed as a concrete new issue. | +| #273 | Fixed: the exact captured Holtburg gap fixture now blocks traversal, requires both real geometry participants, and matches graph/flat collision paths in focused tests. | +| #319 | Fixed and user-gated in placement Campaign C4 route 7: player-parented children receive canonical-cell propagation through the unified owner. | +| #333 | Fixed: the broadphase no longer rejects an off-centre BSP part by measuring reach only from the part origin; regression coverage pins the case. | +| #348 | Fixed: cursor objects are process-lifetime cached instead of being recreated on every alternation; later long connected sessions did not reproduce handle exhaustion. | +| #394 | Fixed: Configure Keyboard row captions use the authored serif DAT font, with current layout tests and later keyboard gates. | +| #395 | Fixed: key captions resolve through retail's DAT/localized key-name pipeline rather than raw enum spellings. | +| #414 | Fixed: logout teardown no longer leaves the cursor in raw-captured fly mode. | +| #415 | Fixed: deferred UI-probe wait verbs are bound independently of the optional artifact-directory setting. | +| #416 | Fixed: roster rollover state clears when the pointer leaves a row. | +| #417 | Fixed: ambience ownership is reset on world logout and does not continue/re-fire at character selection. | +| #419 | Fixed and explicitly owner-accepted: the portal rim/ring artifact disappeared after matching retail's view-plane exit animation. | +| #420 | Fixed: `UiButton` safely handles the character-select media-state case that previously produced the null-key crash. | +| #424 | Fixed: exclusive-fullscreen alt-tab no longer sends a zero-area extent into render-pack activation validation. | +| #426 | Fixed: NO_POS_UVS/solid-colour faces are extracted and rendered instead of being discarded as having no positive face. | +| #432 | Disproved as a production performance bug: the allocation/frame-time spike came from diagnostics being enabled, not the normal client path. | +| #443 | Fixed and owner-passed: paperdoll/private-viewport ownership now hydrates on first open without the intermittent missing/delayed doll. | + +## Recommended action + +1. Keep `#64`, `#235`, and `#263` closed per the owner's explicit post-audit + decision. If one becomes a priority later, reopen it or file a fresh issue + with a current reproduction. +2. Run one compact current-binary gate for the 11 verification items; keep each + closed if the named route passes, otherwise reopen it with the new evidence. +3. Leave the remaining 43 closed. Do not resurrect their stale titles merely + because their original status text once said `OPEN`. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index bfb78468..cf0bf2d1 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -11,6 +11,7 @@ using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Core.Combat; using AcDream.Core.Lighting; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.World; @@ -393,7 +394,10 @@ internal sealed class FrameRootCompositionPhase live.EnvCellRenderer!, foundation.SceneLighting!, d.RenderRange, - skyPesFrame); + skyPesFrame, + persistentDaylight: () => + d.Runtime.CharacterOwner.Options.GetOptionBit( + CharacterOptionId.PersistentAtDay)); var worldRenderFrameBuilder = new WorldRenderFrameBuilder( new RuntimeWorldFrameCameraSource( host.CameraController, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index cb0899e0..af8a6608 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -27,7 +27,6 @@ using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; -using DatReaderWriter.DBObjs; using Silk.NET.Input; using Silk.NET.Windowing; @@ -716,19 +715,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // outside the lock matches this file's existing pattern // elsewhere (construct once, lock only around Resolve calls). var characterCreationStrings = new DatStringResolver(d.Dats); - // CC5 review fix round F3 (2026-08-16): read the global - // SkillTable (portal.dat 0x0E000004 — the SAME file - // ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and - // LiveSessionRuntimeFactory.CreateCharacterBindings already read) - // ONCE at composition time, under the DatLock DatCollection's - // thread-safety contract requires — mirrors LiveSkillCreditResolver's - // own constructor-time load. The resolver itself does no further - // DAT access per call (pure SkillFormula arithmetic), so the - // Summary page's GetSkillScore binding below needs no lock. - SkillTable? chargenSkillTable; - lock (d.DatLock) - chargenSkillTable = d.Dats.Get(0x0E000004u); - var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable); + // #404: ChargenTableReader already loads the global SkillTable and + // projects its formulas into this immutable options model. Reuse + // that single source of truth; summary score reads are pure and + // need neither another DAT read nor another DatLock acquisition. + var chargenSkillScoreResolver = new ChargenSkillScoreResolver( + d.Runtime.CharacterCreation.Options); // Campaign QT slice QT5: lazily loaded on first open (the panel // is hidden at mount), then held for the session. AcDream.Core.Quests.ContractCatalog? contractCatalog = null; @@ -1122,7 +1114,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory MapHouse: new MapHouseRuntimeBindings( CurrentCalendar: d.CurrentCalendar, PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u, - HouseLines: () => d.Runtime.HouseOwner.Lines), + HousePosition: () => d.Runtime.HouseOwner.Position, + HouseLines: () => d.Runtime.HouseOwner.Lines, + HousePanelLines: () => d.Runtime.HouseOwner.PanelLines), // Campaign QT slice QT5. The catalog is read from the dats // ONCE and cached: it is immutable installed content, and the // panel would otherwise re-read a 322-entry table on every diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index da60f580..7ff381ab 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -742,6 +742,7 @@ internal sealed class SessionPlayerCompositionPhase d.EntityObjects.Objects, live.LiveEntities, hydration, + d.Actions.Selection, () => d.UpdateClock.SimulationTimeSeconds); bindings.Adopt( "inventory world-drop projection", diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 2a72e03a..48c1dbb0 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -383,6 +383,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()), ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()), ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)), + ShowClientLocalMessage: text => + InvokeClient(b => b.ShowClientLocalMessage(text)), ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)), PlayerPublicWeenieBitfield: () => ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)), @@ -443,7 +445,91 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)), RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()), RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)), - AbandonHouse: () => InvokeClient(static b => b.AbandonHouse())); + AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()), + Administration: BuildGuardedAdministration(), + IsPersistentDaylight: () => + ReadClient(static b => b.IsPersistentDaylight(), false), + SetPersistentDaylight: value => + InvokeClient(b => b.SetPersistentDaylight(value)), + SetLandscapeRadius: radius => + InvokeClient(b => b.SetLandscapeRadius(radius)), + SetFieldOfView: degrees => + InvokeClient(b => b.SetFieldOfView(degrees))); + + private ClientCommandController.AdministrationBindings BuildGuardedAdministration() => + new( + BreakAllegianceBoot: (name, account) => + InvokeClient(b => b.Administration.BreakAllegianceBoot(name, account)), + AllegianceChatBoot: (name, reason) => + InvokeClient(b => b.Administration.AllegianceChatBoot(name, reason)), + AllegianceChatGag: (name, enabled) => + InvokeClient(b => b.Administration.AllegianceChatGag(name, enabled)), + AllegianceBroadcast: text => + InvokeClient(b => b.Administration.AllegianceBroadcast(text)), + ListAllegianceBans: () => + InvokeClient(static b => b.Administration.ListAllegianceBans()), + AddAllegianceBan: name => + InvokeClient(b => b.Administration.AddAllegianceBan(name)), + RemoveAllegianceBan: name => + InvokeClient(b => b.Administration.RemoveAllegianceBan(name)), + ListAllegianceOfficers: () => + InvokeClient(static b => b.Administration.ListAllegianceOfficers()), + ClearAllegianceOfficers: () => + InvokeClient(static b => b.Administration.ClearAllegianceOfficers()), + SetAllegianceOfficer: (name, level) => + InvokeClient(b => b.Administration.SetAllegianceOfficer(name, level)), + RemoveAllegianceOfficer: name => + InvokeClient(b => b.Administration.RemoveAllegianceOfficer(name)), + ListAllegianceOfficerTitles: () => + InvokeClient(static b => b.Administration.ListAllegianceOfficerTitles()), + ClearAllegianceOfficerTitles: () => + InvokeClient(static b => b.Administration.ClearAllegianceOfficerTitles()), + SetAllegianceOfficerTitle: (level, title) => + InvokeClient(b => b.Administration.SetAllegianceOfficerTitle(level, title)), + QueryAllegianceName: () => + InvokeClient(static b => b.Administration.QueryAllegianceName()), + SetAllegianceName: name => + InvokeClient(b => b.Administration.SetAllegianceName(name)), + ClearAllegianceName: () => + InvokeClient(static b => b.Administration.ClearAllegianceName()), + AllegianceLockAction: action => + InvokeClient(b => b.Administration.AllegianceLockAction(action)), + SetAllegianceApprovedVassal: name => + InvokeClient(b => b.Administration.SetAllegianceApprovedVassal(name)), + AllegianceHouseAction: action => + InvokeClient(b => b.Administration.AllegianceHouseAction(action)), + QueryMotd: () => + InvokeClient(static b => b.Administration.QueryMotd()), + SetMotd: motd => + InvokeClient(b => b.Administration.SetMotd(motd)), + ClearMotd: () => + InvokeClient(static b => b.Administration.ClearMotd()), + SetOpenHouseStatus: open => + InvokeClient(b => b.Administration.SetOpenHouseStatus(open)), + AddPermanentGuest: name => + InvokeClient(b => b.Administration.AddPermanentGuest(name)), + RemovePermanentGuest: name => + InvokeClient(b => b.Administration.RemovePermanentGuest(name)), + RemoveAllPermanentGuests: () => + InvokeClient(static b => b.Administration.RemoveAllPermanentGuests()), + ChangeStoragePermission: (name, enabled) => + InvokeClient(b => b.Administration.ChangeStoragePermission(name, enabled)), + AddAllStoragePermission: () => + InvokeClient(static b => b.Administration.AddAllStoragePermission()), + RemoveAllStoragePermission: () => + InvokeClient(static b => b.Administration.RemoveAllStoragePermission()), + RequestFullGuestList: () => + InvokeClient(static b => b.Administration.RequestFullGuestList()), + BootSpecificHouseGuest: name => + InvokeClient(b => b.Administration.BootSpecificHouseGuest(name)), + BootEveryone: () => + InvokeClient(static b => b.Administration.BootEveryone()), + SetHooksVisibility: visible => + InvokeClient(b => b.Administration.SetHooksVisibility(visible)), + ModifyAllegianceGuestPermission: enabled => + InvokeClient(b => b.Administration.ModifyAllegianceGuestPermission(enabled)), + ModifyAllegianceStoragePermission: enabled => + InvokeClient(b => b.Administration.ModifyAllegianceStoragePermission(enabled))); private bool InvokeClient(Action invoke) { diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index ec26a68b..5607a521 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -414,7 +414,8 @@ internal sealed class LiveSessionRuntimeFactory Allegiance: _domain.Runtime.AllegianceOwner, Trade: _domain.Runtime.TradeOwner, House: _domain.Runtime.HouseOwner, - Contracts: _domain.Runtime.ContractsOwner)); + Contracts: _domain.Runtime.ContractsOwner, + PlayerGuid: () => _player.Identity.ServerGuid)); return new GraphicalSessionEventRoute( route, _domain.Runtime, @@ -616,6 +617,9 @@ internal sealed class LiveSessionRuntimeFactory // unstarted — CH4/CH5 scope at the earliest, not CH2. ShowSystemMessage: text => _domain.Communication.Chat.OnSystemMessage(text, 0x00u), + ShowClientLocalMessage: + text => _domain.Communication.AddText( + text, RetailLogTextType.ClientLocal), // SHOULD-FIX 3 (docs/research/2026-08-09-ch2-review-findings.md): // route through the AddText chokepoint instead of the deleted // ChatLog.OnWeenieError, which hardcoded LogTextType 0x00 — @@ -728,7 +732,66 @@ internal sealed class LiveSessionRuntimeFactory LeaveGmChannel: session.SendOffChannel, RecallAllegianceHometown: session.SendRecallAllegianceHometown, RequestAllegianceInfo: session.SendAllegianceInfoRequest, - AbandonHouse: session.SendAbandonHouse), + AbandonHouse: session.SendAbandonHouse, + Administration: new ClientCommandController.AdministrationBindings( + BreakAllegianceBoot: session.SendBreakAllegianceBoot, + AllegianceChatBoot: session.SendAllegianceChatBoot, + AllegianceChatGag: session.SendAllegianceChatGag, + AllegianceBroadcast: text => + session.SendChannel(0x02000000u, text), + ListAllegianceBans: session.SendListAllegianceBans, + AddAllegianceBan: session.SendAddAllegianceBan, + RemoveAllegianceBan: session.SendRemoveAllegianceBan, + ListAllegianceOfficers: session.SendListAllegianceOfficers, + ClearAllegianceOfficers: session.SendClearAllegianceOfficers, + SetAllegianceOfficer: session.SendSetAllegianceOfficer, + RemoveAllegianceOfficer: session.SendRemoveAllegianceOfficer, + ListAllegianceOfficerTitles: session.SendListAllegianceOfficerTitles, + ClearAllegianceOfficerTitles: session.SendClearAllegianceOfficerTitles, + SetAllegianceOfficerTitle: session.SendSetAllegianceOfficerTitle, + QueryAllegianceName: session.SendQueryAllegianceName, + SetAllegianceName: session.SendSetAllegianceName, + ClearAllegianceName: session.SendClearAllegianceName, + AllegianceLockAction: session.SendAllegianceLockAction, + SetAllegianceApprovedVassal: session.SendSetAllegianceApprovedVassal, + AllegianceHouseAction: session.SendAllegianceHouseAction, + QueryMotd: session.SendQueryMotd, + SetMotd: session.SendSetMotd, + ClearMotd: session.SendClearMotd, + SetOpenHouseStatus: session.SendSetOpenHouseStatus, + AddPermanentGuest: session.SendAddPermanentGuest, + RemovePermanentGuest: session.SendRemovePermanentGuest, + RemoveAllPermanentGuests: session.SendRemoveAllPermanentGuests, + ChangeStoragePermission: session.SendChangeStoragePermission, + AddAllStoragePermission: session.SendAddAllStoragePermission, + RemoveAllStoragePermission: session.SendRemoveAllStoragePermission, + RequestFullGuestList: session.SendRequestFullGuestList, + BootSpecificHouseGuest: session.SendBootSpecificHouseGuest, + BootEveryone: session.SendBootEveryone, + SetHooksVisibility: session.SendSetHooksVisibility, + ModifyAllegianceGuestPermission: + session.SendModifyAllegianceGuestPermission, + ModifyAllegianceStoragePermission: + session.SendModifyAllegianceStoragePermission), + IsPersistentDaylight: () => + _domain.Character.Options.GetOptionBit( + CharacterOptionId.PersistentAtDay), + SetPersistentDaylight: enabled => + SendSingleCharacterOption( + (uint)CharacterOptionId.PersistentAtDay, + enabled), + SetLandscapeRadius: radius => + _interaction.Settings.SaveDisplay( + _interaction.Settings.Display with + { + LandscapeDrawDistance = radius, + }), + SetFieldOfView: degrees => + _interaction.Settings.SaveDisplay( + _interaction.Settings.Display with + { + FieldOfView = degrees, + })), _domain.Communication.Chat, _domain.Communication.TurbineChat, PlayerGuid: () => _player.Identity.ServerGuid, diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 18aab4d5..5b914d47 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -19,16 +19,35 @@ internal static class RetailSkillFormula { ArgumentNullException.ThrowIfNull(formula); - uint divisor = unchecked((uint)formula.Divisor); + return TryCalculate( + formula.AdditiveBonus, + formula.Attribute1Multiplier, + formula.Attribute2Multiplier, + formula.Divisor, + attribute1, + attribute2, + out result); + } + + private static bool TryCalculate( + int additiveBonus, + int attribute1Multiplier, + int attribute2Multiplier, + int divisorStorage, + uint attribute1, + uint attribute2, + out uint result) + { + uint divisor = unchecked((uint)divisorStorage); if (divisor == 0u) { result = 0u; return false; } - uint x = unchecked((uint)formula.Attribute1Multiplier); - uint y = unchecked((uint)formula.Attribute2Multiplier); - uint w = unchecked((uint)formula.AdditiveBonus); + uint x = unchecked((uint)attribute1Multiplier); + uint y = unchecked((uint)attribute2Multiplier); + uint w = unchecked((uint)additiveBonus); uint numerator = unchecked(x * attribute1 + y * attribute2 + w); result = (uint)Math.Floor((double)numerator / divisor + 0.5d); return true; @@ -86,6 +105,39 @@ internal static class RetailSkillFormula }; } + /// + /// Projection-owned sibling used by chargen presentation. Keeping the + /// arithmetic here means the UI consumes the immutable model loaded by + /// ChargenTableReader instead of reading the global SkillTable a + /// second time. + /// + public static uint CalculateChargenScore( + ChargenSkillDetail skillDetail, + uint attribute1, + uint attribute2, + ChargenSkillAdvancementClass level) + { + ChargenSkillFormula formula = skillDetail.Formula; + if (!TryCalculate( + formula.AdditiveBonus, + formula.Attribute1Multiplier, + formula.Attribute2Multiplier, + formula.Divisor, + attribute1, + attribute2, + out uint result)) + { + return 0u; + } + + return level switch + { + ChargenSkillAdvancementClass.Trained => result + 5u, + ChargenSkillAdvancementClass.Specialized => result + 10u, + _ => result, + }; + } + /// /// Retail SkillSystem::InqAttributeName @ 0x005c8d90 — the six /// hardcoded attribute display names (matched exactly against @@ -257,7 +309,7 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) /// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling /// of : resolves /// against the SAME -/// global SkillTable (portal.dat 0x0E000004), fed by a +/// global SkillTable projection in , fed by a /// candidate character's CHARGEN attribute spread (, /// keyed the same way AcDream.Runtime.Session.ChargenAttributeId /// already does — verified against DatReaderWriter's own @@ -270,36 +322,33 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) /// dependency of its own — same shape as that composition's existing /// ResolveText binding. /// -internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable) +internal sealed class ChargenSkillScoreResolver(ChargenOptions options) { public uint Resolve( uint skillId, ChargenAttributeValues attributes, ChargenSkillAdvancementClass level) { - if (skillTable?.Skills is null - || !skillTable.Skills.TryGetValue( - (DatReaderWriter.Enums.SkillId)skillId, - out var skillBase)) + if (!options.TryGetSkillDetail(skillId, out ChargenSkillDetail skillDetail)) { return 0u; } - uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes); - uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes); - return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level); + uint attribute1 = ResolveAttribute(skillDetail.Formula.Attribute1, attributes); + uint attribute2 = ResolveAttribute(skillDetail.Formula.Attribute2, attributes); + return RetailSkillFormula.CalculateChargenScore(skillDetail, attribute1, attribute2, level); } private static uint ResolveAttribute( - DatReaderWriter.Enums.AttributeId attributeId, + uint attributeId, ChargenAttributeValues attributes) => attributeId switch { - DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength), - DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance), - DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness), - DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination), - DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus), - DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self), + 1u => (uint)Math.Max(0, attributes.Strength), + 2u => (uint)Math.Max(0, attributes.Endurance), + 3u => (uint)Math.Max(0, attributes.Quickness), + 4u => (uint)Math.Max(0, attributes.Coordination), + 5u => (uint)Math.Max(0, attributes.Focus), + 6u => (uint)Math.Max(0, attributes.Self), _ => 0u, }; } diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index e7722f2c..f7e39298 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -1123,9 +1123,8 @@ internal sealed class LiveEntityNetworkUpdateController /// carve-out is live for both — its AirborneSnap result is exactly /// the dissolved landing scenario, for every guid (see /// ToConstraintArm's A1 mapping and OnPosition's own - /// arm is AirborneSnap handling for the two guid-preserved - /// extras — #316's shadow-publish skip and the interp-clear — that ride - /// along with it). + /// arm is AirborneSnap handling for the remaining player-guid + /// interp-clear. #316 retired the former shadow-publish skip.) /// /// /// @@ -1938,8 +1937,6 @@ internal sealed class LiveEntityNetworkUpdateController RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical; ulong acceptedPositionAuthorityVersion = accepted.PositionAuthorityVersion; - ulong acceptedPositionVelocityAuthorityVersion = - accepted.VelocityAuthorityVersion; if (!_liveEntities.TryGetProjection( acceptedPositionCanonical, out LiveEntityRecord acceptedPositionRecord) @@ -2390,39 +2387,21 @@ internal sealed class LiveEntityNetworkUpdateController update.Position.LandblockId); } - // 4a-family correction (2026-08-04, found and reported while - // pinning C4 route 5's D-P5 no-velocity design): the previous - // comment here claimed "MoveOrTeleport installs that exact vector - // with set_velocity". A byte-level disassembly of the PDB-paired - // binary (0x00516330-0x00516438, every branch) shows - // MoveOrTeleport never reads its velocity argument's stack slot, - // and UnpackPositionEvent performs no set_velocity either — the - // only set_velocity in the whole accepted-Position chain zeroes - // the LOCAL player (@0x004541B4). This call's actual retail - // justification is therefore NOT yet established and needs its - // own audit; what IS still true and unaffected by that finding: - // the canonical seam below wakes the retained ObjectClock and - // body in one operation, and the Position-delta velocity further - // down remains animation diagnostics, never substituted into - // physics. + // #317: a PositionPack carries an optional velocity vector, but + // retail only passes it through HandleReceivedPosition to + // CPhysicsObj::MoveOrTeleport; that function never reads the + // argument (0x00516330-0x00516438). UnpackPositionEvent has no + // set_velocity either. Do not overwrite the physics body's + // velocity here. The wire vector remains available below as the + // remote server-controlled animation/dead-reckoning sample; + // actual authoritative body velocity arrives through the + // separate VectorUpdate handler, which calls set_velocity. if (!_liveEntities.IsCurrentPositionAuthority( positionRecord, acceptedPositionAuthorityVersion)) { return; } - if (_liveEntities.IsCurrentVelocityAuthority( - positionRecord, - acceptedPositionVelocityAuthorityVersion) - && !_liveEntities.TryCommitAuthoritativeVelocity( - positionRecord, - rmState.Body, - acceptedSpawn.Physics?.Velocity - ?? System.Numerics.Vector3.Zero, - _physicsScriptGameTime)) - { - return; - } // C4 route 4b-3 / D4: retail's single ConstrainTo arming site // (@0x00454272) is now entirely post-operation @@ -2492,15 +2471,10 @@ internal sealed class LiveEntityNetworkUpdateController // survive, both named and justified rather than silently kept: // • TS-44 sticky suppression (below) — an NPC-only steady-state // gate; its own register row already describes it that way. - // • The AirborneSnap arm's interp-clear and collision-shadow - // publish (further below) — PRESERVED, not unified, because - // unifying either way would be an unauthorized behaviour - // change: #316 (filed 2026-08-04) is a real, UNMEASURED - // pre-existing player-guid defect (no shadow publish on - // landing) that this behaviour-preserving collapse must not - // fix, and the interp-clear's equivalence could not be proven - // for the steep-non-walkable-landing edge case (see the - // comment at that arm). + // • The AirborneSnap arm's interp-clear (further below) remains + // player-only because its steep-non-walkable equivalence has + // not been proven. #316 later unified collision-shadow + // publication for both guid ranges at the common tail. // nowSec is captured ONCE, shared by both guid ranges (was two // independent DateTime.UtcNow reads before this collapse — a // microsecond-scale skew in acdream-only bookkeeping/diagnostics). @@ -2689,17 +2663,11 @@ internal sealed class LiveEntityNetworkUpdateController // A1 fix) supplies the same arm value the old landing // block hard-coded. // - // #316 (filed 2026-08-04, deliberately NOT fixed here): - // the player-guid copy of this scenario has never - // published the collision shadow (the tail below does, - // for every OTHER arm and for this SAME arm on NPC - // guids) — a real, UNMEASURED pre-existing defect that - // contradicts the file's own #184 Slice 2b design intent - // ("player shadows now follow the resolved body ... - // exactly like NPCs"). Fixing it is a behaviour change - // this collapse may not make; the skip is reproduced - // verbatim at the tail below, keyed on this same `arm` - // value. + // #316 fixed 2026-08-28: the shared tail now publishes + // the resolved collision shadow for player-guid landings + // exactly as it already did for the same NPC arm. The old + // player-only skip left render/body at the landing pose + // while collision remained at its pre-snap position. // // The interp-queue clear is preserved alongside it rather // than unified either way. AdjustOffset's CONTACT_TS gate @@ -2846,29 +2814,22 @@ internal sealed class LiveEntityNetworkUpdateController // rmState.CellId is the server cell adopted above. The root // frame is committed before collision publication, as in retail // SetPositionInternal. The ONE entity-sync + shadow-publish tail - // for every guid and every arm — except the #316-preserved - // exception: a player-guid AirborneSnap arm still commits the - // render entity from the resolved body but does NOT publish the - // shadow, matching its pre-collapse behaviour exactly (see the - // comment at that arm, above). + // now covers every guid and every arm, including #316's formerly + // skipped player-guid AirborneSnap landing. entity.SetPosition(rmState.Body.Position); entity.ParentCellId = rmState.CellId; entity.Rotation = rmState.Body.Orientation; - if (arm is not RemoteContactArm.AirborneSnap - || !IsPlayerGuid(update.Guid)) - { - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, + AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( + _liveEntities, + positionRecord, + entity, + rmState, + acceptedPositionAuthorityVersion, + () => _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - rmState, - _origin.CenterX, - _origin.CenterY)); - } + _origin.CenterX, + _origin.CenterY)); } // F751 is only a notification gate; the accepted Position may arrive diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 08da6b15..efb24b94 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -109,10 +109,17 @@ public static class InteriorEntityPartition HashSet visibleCells, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, - IReadOnlyDictionary? AnimatedById)> landblockEntries) + IReadOnlyDictionary? AnimatedById)> landblockEntries, + FrustumPlanes? frustum = null, + uint neverCullLandblockId = 0u) { var result = new Result(); - Partition(result, visibleCells, landblockEntries); + Partition( + result, + visibleCells, + landblockEntries, + frustum, + neverCullLandblockId); return result; } @@ -131,11 +138,23 @@ public static class InteriorEntityPartition HashSet visibleCells, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, - IReadOnlyDictionary? AnimatedById)> landblockEntries) + IReadOnlyDictionary? AnimatedById)> landblockEntries, + FrustumPlanes? frustum = null, + uint neverCullLandblockId = 0u) { result.ClearForReuse(); foreach (var entry in landblockEntries) { + if (!IsLandblockVisible( + entry.LandblockId, + entry.AabbMin, + entry.AabbMax, + frustum, + neverCullLandblockId)) + { + continue; + } + foreach (var e in entry.Entities) { if (e.MeshRefs.Count == 0) continue; @@ -176,11 +195,18 @@ public static class InteriorEntityPartition IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> landblockEntries, - IObserver? observer) + IObserver? observer, + FrustumPlanes? frustum = null, + uint neverCullLandblockId = 0u) { if (observer is null) { - Partition(result, visibleCells, landblockEntries); + Partition( + result, + visibleCells, + landblockEntries, + frustum, + neverCullLandblockId); return; } @@ -190,6 +216,16 @@ public static class InteriorEntityPartition result.ClearForReuse(); foreach (var entry in landblockEntries) { + if (!IsLandblockVisible( + entry.LandblockId, + entry.AabbMin, + entry.AabbMax, + frustum, + neverCullLandblockId)) + { + continue; + } + foreach (var e in entry.Entities) { if (e.MeshRefs.Count == 0) continue; @@ -247,4 +283,14 @@ public static class InteriorEntityPartition /// public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c); + + private static bool IsLandblockVisible( + uint landblockId, + Vector3 aabbMin, + Vector3 aabbMax, + FrustumPlanes? frustum, + uint neverCullLandblockId) => + frustum is null + || landblockId == neverCullLandblockId + || FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax); } diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index 26e92693..fc4d2ae7 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -159,16 +159,12 @@ internal sealed class LiveEntityAnimationPresenter continue; if (span > 0 && legacyAdvanceSeconds > 0f) { - animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate; - if (animation.CurrFrame > animation.HighFrame) - { - float over = animation.CurrFrame - animation.LowFrame; - animation.CurrFrame = animation.LowFrame + (over % (span + 1)); - } - else if (animation.CurrFrame < animation.LowFrame) - { - animation.CurrFrame = animation.LowFrame; - } + animation.CurrFrame = RetailAnimationCyclePlayback.Advance( + animation.CurrFrame, + animation.LowFrame, + animation.HighFrame, + animation.Framerate, + legacyAdvanceSeconds); } } @@ -279,33 +275,14 @@ internal sealed class LiveEntityAnimationPresenter return false; } - int frameIndex = (int)Math.Floor(animation.CurrFrame); - if (frameIndex < animation.LowFrame - || frameIndex > animation.HighFrame - || frameIndex >= animation.Animation.PartFrames.Count) - { - frameIndex = animation.LowFrame; - } - int nextIndex = frameIndex + 1; - if (nextIndex > animation.HighFrame - || nextIndex >= animation.Animation.PartFrames.Count) - { - nextIndex = animation.LowFrame; - } - float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f); - var frames = animation.Animation.PartFrames[frameIndex].Frames; - var nextFrames = animation.Animation.PartFrames[nextIndex].Frames; - if (partIndex < frames.Count) - { - var first = frames[partIndex]; - var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; - origin = Vector3.Lerp(first.Origin, next.Origin, t); - orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); - return true; - } - origin = default; - orientation = default; - return false; + return RetailAnimationCyclePlayback.TryInterpolatePart( + animation.Animation, + animation.CurrFrame, + animation.LowFrame, + animation.HighFrame, + partIndex, + out origin, + out orientation); } private static void EnsureRetainedPoses(LiveEntityAnimationState animation) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0aa5b947..bc582a3d 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -239,7 +239,9 @@ public sealed class RetailPViewRenderer _partitionResult, prepareCells, ctx.LandblockEntries, - _partitionObserver); + _partitionObserver, + ctx.Frustum, + ctx.PlayerLandblockId ?? 0u); partition = _partitionResult; } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index aec455b6..0bfc9210 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Numerics; using AcDream.Core.Meshing; @@ -52,15 +53,17 @@ public sealed partial class SkyRenderer : IDisposable // Lazily-built GPU resources per sky-GfxObj. private readonly Dictionary> _gpuByGfxObj = new(); - // When did we start running — used to accumulate TexVelocityX/Y over - // real time (independent of the day-fraction clock). - private readonly DateTime _startedAt = DateTime.UtcNow; + // Retail advances animated texture coordinates from Timer::cur_time + // deltas in CPhysics::UseTime. Stopwatch is the matching monotonic clock: + // unlike wall time, OS clock synchronization cannot make rain/cloud UVs + // jump forward or backward. + private readonly long _animationStartedAtTimestamp = Stopwatch.GetTimestamp(); /// /// Campaign V slice V7: pins the sky's scroll phase to a fixed number of - /// seconds instead of reading the wall clock, so two launches agree. + /// seconds instead of advancing the live animation clock, so two launches agree. /// null — the default, and what every ordinary run gets — keeps the - /// wall clock. + /// monotonic real-elapsed-time clock. /// /// Why the sky needs its own pin when the world clock is already /// pinnable. Two independent clocks drive this renderer. The Dereth clock @@ -239,7 +242,9 @@ public sealed partial class SkyRenderer : IDisposable var replaces = PickReplaces(group, dayFraction); float secondsSinceStart = AnimationPhaseSecondsOverride - ?? (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + ?? ElapsedAnimationSeconds( + _animationStartedAtTimestamp, + Stopwatch.GetTimestamp()); for (int i = 0; i < group.SkyObjects.Count; i++) { @@ -454,6 +459,9 @@ public sealed partial class SkyRenderer : IDisposable } } + internal static float ElapsedAnimationSeconds(long startTimestamp, long currentTimestamp) + => (float)Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp).TotalSeconds; + /// /// Campaign V slice V6e: the table slot for one (texture, wrap-mode) pair, /// interning a resident bindless handle on first use. diff --git a/src/AcDream.App/Rendering/SkyPesFrameController.cs b/src/AcDream.App/Rendering/SkyPesFrameController.cs index ab087831..64304022 100644 --- a/src/AcDream.App/Rendering/SkyPesFrameController.cs +++ b/src/AcDream.App/Rendering/SkyPesFrameController.cs @@ -43,6 +43,9 @@ namespace AcDream.App.Rendering; /// internal sealed class SkyPesFrameController { + private static readonly Matrix4x4[] IdentityPartPose = + [Matrix4x4.Identity]; + private readonly record struct SkyPesKey( int ObjectIndex, uint GfxObjId, @@ -122,12 +125,23 @@ internal sealed class SkyPesFrameController ? ParticleRenderPass.SkyPostScene : ParticleRenderPass.SkyPreScene; _particles.SetEntityRenderPass(ownerId, renderPass); + // The sky cell follows the viewer. Keep the script dispatch + // anchor on that same current-frame pose: SoundTweaked hooks in + // the Rainy carriers are ordinary world sounds, and a stale + // creation-time anchor falls beyond retail's audible radius as + // soon as login/teleport/movement displaces the camera. + _scripts.SetOwnerAnchor(ownerId, cameraWorldPosition); Quaternion rotation = Rotation(skyObject, dayFraction); _poses.Publish( ownerId, Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(cameraWorldPosition), - Array.Empty(), + // Dereth's scripted sky carriers (including lightning Setup + // 0x02000BA6) are one-part dummy anchors whose default part-0 + // frame is identity. Their CreateParticle hooks target part + // 0, not the -1 root sentinel, so a root-only synthetic pose + // makes a live carrier look pose-less to ParticleHookSink. + IdentityPartPose, cellId: 0u); if (_active.Contains(key) || _missing.Contains(key)) diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index 1e22472b..bf51a132 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -264,11 +264,8 @@ public sealed unsafe partial class EnvCellRenderer { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; int groupIndex = drawRange.GroupIndex; - var cullMode = (CullMode)(groupIndex % 4); - // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock - // uniformly, but the room surfaces need to be visible from inside. - // Render cell polys double-sided, exactly as the GL arm does. - if (cullMode == CullMode.Landblock) cullMode = CullMode.None; + CullMode cullMode = ResolveRetailCellShellCullMode( + (CullMode)(groupIndex % 4)); bool isAdditive = groupIndex >= 4; IGpuPipeline rangeBasePipeline = isAdditive @@ -361,9 +358,8 @@ public sealed unsafe partial class EnvCellRenderer for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; - var cullMode = (CullMode)(drawRange.GroupIndex % 4); - if (cullMode == CullMode.Landblock) - cullMode = CullMode.None; + CullMode cullMode = ResolveRetailCellShellCullMode( + (CullMode)(drawRange.GroupIndex % 4)); SetCullMode(encoder, cullMode); pushConstants.DrawIdOffset = drawRange.FirstCommand; encoder.SetPushConstants(in pushConstants); @@ -417,6 +413,25 @@ public sealed unsafe partial class EnvCellRenderer } } + /// + /// Resolves a CellStruct polygon's DAT sides_type to the render + /// state used by retail's constructed EnvCell mesh. The similarly named + /// values on Polygon.SidesType are not GPU + /// cull states: 0 emits the positive face, 1 emits that face twice with + /// reversed indices, and 2 emits the positive and negative surface. + /// D3DPolyRender::ConstructMesh @ 0x0059DFA0 performs that geometry + /// expansion, then every subset is drawn with D3DCULL_CW through + /// RenderMeshSubset @ 0x0059CA10. + /// already performs the identical expansion, so every shell batch must + /// cull clockwise here. Returning for DAT 0 + /// was #178's Phase-A8 double-sided stopgap. + /// + internal static CullMode ResolveRetailCellShellCullMode(CullMode sidesType) + { + _ = sidesType; + return CullMode.Clockwise; + } + /// /// Reserves this frame's ring, copies into it, and binds the slice. A /// logically empty section still reserves one element so the bound range is diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 5426231e..19371426 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -462,6 +462,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation private readonly SceneLightingUboBinding? _lightingUbo; private readonly IWorldRenderRangeSource _ranges; private readonly SkyPesFrameController? _skyPes; + private readonly Func _persistentDaylight; private readonly HashSet _visibleCells = []; private bool _visibleCellsValid; @@ -473,7 +474,8 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation EnvCellRenderer? environmentCells, SceneLightingUboBinding? lightingUbo, IWorldRenderRangeSource ranges, - SkyPesFrameController? skyPes) + SkyPesFrameController? skyPes, + Func? persistentDaylight = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); @@ -483,6 +485,7 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation _lightingUbo = lightingUbo; _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges)); _skyPes = skyPes; + _persistentDaylight = persistentDaylight ?? (static () => false); } public void Prepare( @@ -500,7 +503,14 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation activeDayGroup, camera.Position); - UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell); + // LScape::set_landscape_lighting @0x005054D0 keeps the live sky/fog + // clock but, when PersistentAtDay is set, asks the active region for + // lighting at exactly 0.5 (noon). Do not pin WorldTime.DayFraction: + // clouds, celestial objects, fog, and scripts must keep advancing. + SkyKeyframe landscapeLighting = _persistentDaylight() + ? _worldTime.SkyAtDayFraction(0.5f) + : foundation.Sky; + UpdateSunFromSky(landscapeLighting, roots.PlayerInsideCell); _lighting.UpdateViewerLight(roots.PlayerViewPosition); _lighting.Tick(camera.Position); _lighting.BuildPointLightSnapshot( diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 41790ed5..fa0ad2b0 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -173,7 +173,13 @@ public sealed record RuntimeOptions( // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), - RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + // The retained retail UI is the product's only presentation + // stack. It is therefore default-on for every launch path; + // literal 0 remains an explicit diagnostic/headless opt-out. + RetailUi: !string.Equals( + env("ACDREAM_RETAIL_UI"), + "0", + StringComparison.Ordinal), OpenCharacterCreationOnStart: IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), @@ -293,14 +299,6 @@ public sealed record RuntimeOptions( PreparedAssetEffectiveRecipeVersion = content?.PreparedAssetEffectiveRecipeVersion, LiveMode = true, - // Campaign LA gate round 2: a session-config launch IS a product - // launch — the retail UI is the shipped UI, not a dev option. - // ACDREAM_RETAIL_UI remains the opt-in for env-var dev launches, - // but the launcher strips ACDREAM_* from children (LA11 isolation), - // so inheriting the env default here shipped a client with world - // rendering and NO interface at all — the guiSelect flow's - // character screen included. - RetailUi = true, LiveHost = session.Endpoint.Host, LivePort = session.Endpoint.Port, LiveUser = session.Account, diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 7838812d..ef12d68f 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -161,11 +161,18 @@ internal sealed class RuntimeSettingsController : Func? characterOptionValue = null) { _storage = storage ?? throw new ArgumentNullException(nameof(storage)); - _resolveQuality = resolveQuality ?? ResolveQuality; + Display = _storage.LoadDisplay(); + // Render.LandscapeDrawDistance is one of retail's actual quality + // dimensions, not a menu index. Apply it to the production far tier + // before environment overrides; ACDREAM_FAR_RADIUS therefore keeps + // its documented highest precedence for diagnostic runs. + _resolveQuality = resolveQuality + ?? (preset => ResolveQuality( + preset, + Display.LandscapeDrawDistance)); _log = log ?? Console.WriteLine; _characterOptionValue = characterOptionValue; - Display = _storage.LoadDisplay(); Audio = _storage.LoadAudio(); Chat = _storage.LoadChat(); _defaultCharacter = _storage.LoadCharacter(DefaultToonKey); @@ -613,6 +620,35 @@ internal sealed class RuntimeSettingsController : /// public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke(); - private static QualitySettings ResolveQuality(QualityPreset preset) => - QualitySettings.WithEnvOverrides(QualitySettings.From(preset)); + private static QualitySettings ResolveQuality( + QualityPreset preset, + int landscapeDrawDistance) + { + QualitySettings quality = ApplyLandscapeDrawDistance( + QualitySettings.From(preset), + landscapeDrawDistance); + return QualitySettings.WithEnvOverrides(quality); + } + + internal static QualitySettings ApplyLandscapeDrawDistance( + QualitySettings quality, + int landscapeDrawDistance) + { + // Retail's enum contains 3,5,8,11,15,25 and @render accepts every + // integer in [5,25]. Values outside the union's structural [3,25] + // range can only come from an old/corrupt settings file, so preserve + // the preset rather than constructing an invalid streaming window. + if (landscapeDrawDistance is >= 3 and <= 25) + { + quality = quality with + { + NearRadius = Math.Min( + quality.NearRadius, + landscapeDrawDistance), + FarRadius = landscapeDrawDistance, + }; + } + + return quality; + } } diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index 0b34596e..532f1184 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -3,6 +3,7 @@ using AcDream.Core.Chat; using AcDream.Core.Physics; using AcDream.Core.Ui; using AcDream.Core.Social; +using AcDream.Runtime.Chat; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; @@ -15,6 +16,50 @@ namespace AcDream.App.UI; /// public sealed class ClientCommandController { + /// + /// Typed network effects used by retail's allegiance/house management + /// command family. Grouped separately so the main command binding remains + /// readable and every operation keeps a semantic name instead of exposing + /// raw opcodes to the UI layer. + /// + public sealed record AdministrationBindings( + Action BreakAllegianceBoot, + Action AllegianceChatBoot, + Action AllegianceChatGag, + Action AllegianceBroadcast, + Action ListAllegianceBans, + Action AddAllegianceBan, + Action RemoveAllegianceBan, + Action ListAllegianceOfficers, + Action ClearAllegianceOfficers, + Action SetAllegianceOfficer, + Action RemoveAllegianceOfficer, + Action ListAllegianceOfficerTitles, + Action ClearAllegianceOfficerTitles, + Action SetAllegianceOfficerTitle, + Action QueryAllegianceName, + Action SetAllegianceName, + Action ClearAllegianceName, + Action AllegianceLockAction, + Action SetAllegianceApprovedVassal, + Action AllegianceHouseAction, + Action QueryMotd, + Action SetMotd, + Action ClearMotd, + Action SetOpenHouseStatus, + Action AddPermanentGuest, + Action RemovePermanentGuest, + Action RemoveAllPermanentGuests, + Action ChangeStoragePermission, + Action AddAllStoragePermission, + Action RemoveAllStoragePermission, + Action RequestFullGuestList, + Action BootSpecificHouseGuest, + Action BootEveryone, + Action SetHooksVisibility, + Action ModifyAllegianceGuestPermission, + Action ModifyAllegianceStoragePermission); + public sealed record Bindings( Action TeleportToLifestone, Action TeleportToMarketplace, @@ -27,6 +72,7 @@ public sealed class ClientCommandController Action ToggleFrameRate, Action ToggleUiLock, Action ShowSystemMessage, + Action ShowClientLocalMessage, Action ShowWeenieError, Func PlayerPublicWeenieBitfield, Func ClientVersion, @@ -76,13 +122,63 @@ public sealed class ClientCommandController Action LeaveGmChannel, Action RecallAllegianceHometown, Action RequestAllegianceInfo, - Action AbandonHouse); + Action AbandonHouse, + AdministrationBindings Administration, + Func IsPersistentDaylight, + Action SetPersistentDaylight, + Action SetLandscapeRadius, + Action SetFieldOfView); private readonly Bindings _bindings; + private readonly RetailAdministrationCommandDispatcher _administration; public ClientCommandController(Bindings bindings) { _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + AdministrationBindings actions = bindings.Administration; + _administration = new RetailAdministrationCommandDispatcher( + new RetailAdministrationCommandDispatcher.FeedbackBindings( + bindings.ShowSystemMessage, + bindings.ShowClientLocalMessage, + bindings.SetSingleCharacterOption, + bindings.RequestAllegianceInfo), + new RetailAdministrationCommandDispatcher.ActionBindings( + actions.BreakAllegianceBoot, + actions.AllegianceChatBoot, + actions.AllegianceChatGag, + actions.AllegianceBroadcast, + actions.ListAllegianceBans, + actions.AddAllegianceBan, + actions.RemoveAllegianceBan, + actions.ListAllegianceOfficers, + actions.ClearAllegianceOfficers, + actions.SetAllegianceOfficer, + actions.RemoveAllegianceOfficer, + actions.ListAllegianceOfficerTitles, + actions.ClearAllegianceOfficerTitles, + actions.SetAllegianceOfficerTitle, + actions.QueryAllegianceName, + actions.SetAllegianceName, + actions.ClearAllegianceName, + actions.AllegianceLockAction, + actions.SetAllegianceApprovedVassal, + actions.AllegianceHouseAction, + actions.QueryMotd, + actions.SetMotd, + actions.ClearMotd, + actions.SetOpenHouseStatus, + actions.AddPermanentGuest, + actions.RemovePermanentGuest, + actions.RemoveAllPermanentGuests, + actions.ChangeStoragePermission, + actions.AddAllStoragePermission, + actions.RemoveAllStoragePermission, + actions.RequestFullGuestList, + actions.BootSpecificHouseGuest, + actions.BootEveryone, + actions.SetHooksVisibility, + actions.ModifyAllegianceGuestPermission, + actions.ModifyAllegianceStoragePermission)); } public void Execute(ExecuteClientCommandCmd command) @@ -146,6 +242,23 @@ public sealed class ClientCommandController case ClientCommandId.ToggleFrameRate: _bindings.ToggleFrameRate(); break; + // ClientCommunicationSystem::DoDay @0x005706F0. Retail toggles + // LScape::m_fAlwaysDaylight, then writes the same value to the + // PersistentAtDay character option and prints one exact line. + case ClientCommandId.TogglePersistentDaylight: + { + bool enabled = !_bindings.IsPersistentDaylight(); + _bindings.SetPersistentDaylight(enabled); + _bindings.ShowSystemMessage(enabled + ? "Let there be light!" + : "Normality has been restored."); + break; + } + // ClientCommunicationSystem::DoRenderOption @0x0057E120 -> + // GraphicsOptions::HandleRenderOption @0x00455C30. + case ClientCommandId.RenderOption: + ExecuteRenderOption(command.Arguments); + break; // DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and // broadcasts the new state to every UI element. case ClientCommandId.ToggleUiLock: @@ -341,9 +454,29 @@ public sealed class ClientCommandController case ClientCommandId.AllegianceHometown: _bindings.RecallAllegianceHometown(); break; - // GameActionAllegianceInfoRequest — "@allegiance info [name]". + // ClientCommunicationSystem::DoAllegiance/DoHouse management + // family. The shared Runtime dispatcher keeps graphical and + // headless grammar/refusal behavior identical. case ClientCommandId.AllegianceInfo: - _bindings.RequestAllegianceInfo(command.Arguments.Trim()); + case ClientCommandId.AllegianceBoot: + case ClientCommandId.AllegianceBan: + case ClientCommandId.AllegianceChat: + case ClientCommandId.AllegianceBroadcast: + case ClientCommandId.AllegianceOfficer: + case ClientCommandId.AllegianceOfficerTitle: + case ClientCommandId.AllegianceName: + case ClientCommandId.AllegianceLock: + case ClientCommandId.AllegianceHouse: + case ClientCommandId.AllegianceMotd: + case ClientCommandId.AllegianceUnrecognizedSubcommand: + case ClientCommandId.HouseOpenStatus: + case ClientCommandId.HouseStorage: + case ClientCommandId.HouseBoot: + case ClientCommandId.HouseBootAll: + case ClientCommandId.HouseGuests: + case ClientCommandId.HouseHooks: + case ClientCommandId.HouseUnrecognizedSubcommand: + _ = _administration.TryExecute(command.Command, command.Arguments); break; // GameActionHouseAbandon — "@house abandon". Retail's abandon // branch (DoHouse @ 0x00580D58) opens a FIRST confirmation @@ -384,6 +517,7 @@ public sealed class ClientCommandController } } + private void ExecuteUiProfile(string arguments, bool save) { string[] parts = SplitArguments(arguments); @@ -405,6 +539,94 @@ public sealed class ClientCommandController else _bindings.LoadUi(name); } + /// + /// Exact retail GraphicsOptions::HandleRenderOption @0x00455C30 + /// surface. It has only two options, ignores surplus argv entries, uses + /// C atoi semantics for the value, and silently accepts an unknown + /// option name. + /// + private void ExecuteRenderOption(string arguments) + { + string[] parts = SplitArguments(arguments); + if (parts.Length == 0 + || parts[0].Equals("usage", StringComparison.OrdinalIgnoreCase)) + { + _bindings.ShowSystemMessage(RetailCommandHelpTable.Render.TrimEnd('\n')); + return; + } + + if (parts[0].Equals("radius", StringComparison.OrdinalIgnoreCase)) + { + if (parts.Length < 2) + { + _bindings.ShowSystemMessage("Must specify a radius"); + return; + } + + int radius = RetailAtoi(parts[1]); + if (radius is < 5 or > 25) + { + _bindings.ShowSystemMessage("Radius must be between 5 and 25"); + return; + } + + _bindings.SetLandscapeRadius(radius); + _bindings.ShowSystemMessage("Landscape radius set"); + return; + } + + if (!parts[0].Equals("fov", StringComparison.OrdinalIgnoreCase)) + return; + + if (parts.Length < 2) + { + _bindings.ShowSystemMessage("Must specify a field of view"); + return; + } + + int fieldOfView = RetailAtoi(parts[1]); + if (fieldOfView is < 10 or > 160) + { + _bindings.ShowSystemMessage( + "Field of view must be between 10 and 160"); + return; + } + + _bindings.SetFieldOfView(fieldOfView); + _bindings.ShowSystemMessage("Field of view set"); + } + + private static int RetailAtoi(string value) + { + if (string.IsNullOrEmpty(value)) + return 0; + + int index = 0; + int sign = 1; + if (value[0] is '+' or '-') + { + if (value[0] == '-') + sign = -1; + index++; + } + + long result = 0; + bool sawDigit = false; + while (index < value.Length && value[index] is >= '0' and <= '9') + { + sawDigit = true; + result = Math.Min( + (long)int.MaxValue + (sign < 0 ? 1L : 0L), + result * 10L + (value[index] - '0')); + index++; + } + + if (!sawDigit) + return 0; + long signed = sign < 0 ? -result : result; + return (int)Math.Clamp(signed, int.MinValue, int.MaxValue); + } + private bool RequireNoArguments(string arguments, string usage) { if (string.IsNullOrWhiteSpace(arguments)) return true; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 40bac999..a341ac2c 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -306,41 +306,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; - // R3-3 (Campaign CC gate round 1 re-test 2): the title - // (0x100003fb, Y=435, Height=100) and description (0x100003fc, - // Y=460, Height=100) panes' own AUTHORED boxes overlap by 75px - // (live-DAT-measured) — retail relies on vertical JUSTIFICATION, - // not disjoint rects, to keep the two visually separate. Neither - // element authors dat property 0x15 (live-DAT-probe-confirmed - // absent on both), so both fall to whatever the unauthored default - // resolves to. Byte-traced against retail's own - // UIElement_Text::UIElement_Text ctor @0x004685ff - // (this->m_eVerticalJustification = 4) cross-referenced with - // UIElement_Text::CalcJustification @0x00467260 (the ACTUAL - // enum semantics: ecx_5==1 -> Center, ecx_5==3||5 -> the FAR edge - // (Bottom), any other value including the ctor's own default of 4 - // -> edi=0, the NEAR edge, i.e. Top): the correct unauthored - // default is TOP, not Center. This port's shared - // ElementReader/DatWidgetFactory VJustify mapping and field - // default both currently resolve an absent 0x15 to Center — a - // client-wide mismatch with real retail semantics that is NOT - // fixed here (filed as ISSUES.md #410; the blast radius spans - // every already-shipped DAT-imported UiText that relies on the - // CURRENT Center default, so a global remap needs its own - // dedicated investigation + regression sweep, not a bundled - // fix inside this page). Scoped correction: force these two - // specific panes to the value retail's ctor actually resolves - // to. Under Top justification the title (OneLine, ~1 line) sits - // near its box's own top (global Y~435) and the description - // (multi-line, honoring the SAME justification via - // ConfigureDatState's _honorDatVerticalJustification) starts near - // ITS box's own top (global Y~460) — the two boxes' TOP edges are - // 25px apart, so short/typical content no longer collides even - // though the boxes' full 100px extents still overlap on paper. - if (_infoTitle is { } infoTitle) - infoTitle.VerticalJustify = VJustify.Top; - if (_infoText is { } infoText) - infoText.VerticalJustify = VJustify.Top; + // The two panes author no 0x15. The shared importer now applies + // retail's constructor default (raw 4 -> Top), so no page-local + // justification correction is needed. // R4-3 (Campaign CC gate round 1 re-test 3): the description pane's // own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT- diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 7cbe7810..2bd11d05 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -309,40 +309,6 @@ internal sealed class CharacterCreationUiController : IDisposable _townTab.OnClick = () => ApplyProgressState(Page.Town); _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); - // GF-13 (Campaign CC gate round 1, Batch A): honor the authored - // Invisible flag (dat property 0x3B) chargen-scoped only — see - // HideAuthoredInvisibleElements's own doc comment. - HideAuthoredInvisibleElements(Root); - } - - /// - /// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate - /// reported an acdream-only "-Non-admin or Non-envoy" text leak below the - /// Summary name field. Root cause: elements 0x10000403 ("Non- - /// Admin") and 0x10000494 ("Non-Envoy") author dat property - /// 0x3B (Invisible) = — retail's - /// UIElement::OnSetAttribute @0x00462d80 case 8 - /// (GetPropertyName()-0x33 == 8, property id 0x3B) hides any - /// element authoring it via SetVisible(value == 0). acdream's - /// shared never read this property at all - /// (it now does, into / - /// , a pure data addition), so - /// every one of the 1,083 elements client-wide that author it rendered - /// regardless. A blanket importer-wide honor is its own separately-gated - /// visual sweep (docs/ISSUES.md #408) — this method is the NARROW, - /// chargen-scoped fix: walk this screen's own mounted subtree once at - /// construction and hide anything the dat itself marked hidden, by the - /// AUTHORED FLAG rather than a hardcoded id list, so any other - /// authored-invisible element under this root (not just the two the user - /// happened to see) is honored the same way. Register AP-230 records the - /// scoped-vs-general split. - /// - private static void HideAuthoredInvisibleElements(UiElement element) - { - if (element.AuthoredInvisible) - element.Visible = false; - foreach (UiElement child in element.Children) - HideAuthoredInvisibleElements(child); } internal UiElement Root => _layout.Root; diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index f42e241a..53df4ffa 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -23,7 +23,7 @@ internal sealed class CharacterManagementUiController : IDisposable /// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id /// switch is keyed off idElement - 0x1000039d (the listbox base); /// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers - /// (Register@0x0047a69e) — out of scope this round (finding 1 note). + /// (Register@0x004E7500). /// internal const uint CreditsElementId = 0x100003A3u; /// Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250. @@ -69,6 +69,7 @@ internal sealed class CharacterManagementUiController : IDisposable private uint _errorDialogContext; private uint _confirmExitDialogContext; private bool _active; + private bool _presentationSuppressed; private bool _restoreCommandInFlight; private bool _suppressDialogCallbacks; private bool _disposed; @@ -86,7 +87,8 @@ internal sealed class CharacterManagementUiController : IDisposable UiButton exit, RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, - DialogStrings strings) + DialogStrings strings, + Action? openCredits) { _host = host; _layout = layout; @@ -139,13 +141,12 @@ internal sealed class CharacterManagementUiController : IDisposable _delete.OnClick = RequestDelete; _restore.OnClick = RestoreSelected; - // Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of - // scope this round (finding 1 note) — same "future campaign, visibly - // ghosted, no invented action" treatment as Create above. Filed as - // issue #400. + // ListenToElementMessage @0x004ED5A0 case 6 queues UI mode + // 0x10000005, registered by gmCreditsUI::Register @0x004E7500. + // The local composition callback performs that same screen swap. _credits.Visible = true; - _credits.Enabled = false; - _credits.OnClick = null; + _credits.Enabled = openCredits is not null; + _credits.OnClick = openCredits; _exit.OnClick = RequestExit; // World name (retail UpdateWorldName@0x004ec120 / @@ -168,6 +169,7 @@ internal sealed class CharacterManagementUiController : IDisposable { if (_disposed) return; + _presentationSuppressed = false; Deactivate(); _lastRevision = long.MinValue; } @@ -178,7 +180,8 @@ internal sealed class CharacterManagementUiController : IDisposable Func templateResolver, RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, - DialogStrings strings) + DialogStrings strings, + Action? openCredits = null) { CharacterManagementUiController? controller = CreateDetached( host, @@ -186,7 +189,8 @@ internal sealed class CharacterManagementUiController : IDisposable templateResolver, dialogs, bindings, - strings); + strings, + openCredits); if (controller is null) return null; @@ -208,7 +212,8 @@ internal sealed class CharacterManagementUiController : IDisposable Func templateResolver, RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, - DialogStrings strings) + DialogStrings strings, + Action? openCredits = null) { ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(layout); @@ -255,7 +260,8 @@ internal sealed class CharacterManagementUiController : IDisposable exit, dialogs, bindings, - strings); + strings, + openCredits); } catch { @@ -264,6 +270,7 @@ internal sealed class CharacterManagementUiController : IDisposable enter.OnClick = null; delete.OnClick = null; restore.OnClick = null; + credits.OnClick = null; exit.OnClick = null; throw; } @@ -292,6 +299,13 @@ internal sealed class CharacterManagementUiController : IDisposable if (_disposed) return; + if (_presentationSuppressed) + { + Deactivate(); + _lastRevision = long.MinValue; + return; + } + IRuntimeCharacterSelectionView? view = _bindings.View(); RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default; if (view is null || !snapshot.IsActive) @@ -380,6 +394,7 @@ internal sealed class CharacterManagementUiController : IDisposable _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; + _credits.OnClick = null; _exit.OnClick = null; foreach (UiButton row in _rows) { @@ -394,6 +409,21 @@ internal sealed class CharacterManagementUiController : IDisposable } } + /// + /// Local UI-mode seam used by . Retail + /// destroys/recreates the two frameworks through QueueUIMode; hiding this + /// retained root while preserving Runtime's character-selection owner has + /// the same observable behavior without duplicating state. + /// + internal void SetPresentationSuppressed(bool suppressed) + { + if (_disposed || _presentationSuppressed == suppressed) + return; + _presentationSuppressed = suppressed; + _lastRevision = long.MinValue; + Tick(); + } + private static bool TryCaptureRoster( IRuntimeCharacterSelectionView view, RuntimeCharacterSelectionSnapshot expected, diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs index e2748193..dc487514 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs @@ -20,13 +20,15 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable private readonly CharacterSelectionRuntimeBindings _bindings; private readonly Func _ensureDialogs; private readonly Func _loadResources; + private readonly Action? _openCredits; private bool _disposed; public CharacterManagementUiMountCoordinator( UiRoot host, CharacterSelectionRuntimeBindings bindings, Func ensureDialogs, - Func loadResources) + Func loadResources, + Action? openCredits = null) { _host = host ?? throw new ArgumentNullException(nameof(host)); _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); @@ -34,6 +36,7 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable ?? throw new ArgumentNullException(nameof(ensureDialogs)); _loadResources = loadResources ?? throw new ArgumentNullException(nameof(loadResources)); + _openCredits = openCredits; } public CharacterManagementUiController? Controller { get; private set; } @@ -58,9 +61,10 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable _host, resources.Layout, resources.TemplateResolver, - dialogs, - _bindings, - resources.Strings); + dialogs, + _bindings, + resources.Strings, + _openCredits); if (candidate is null) return; diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index be320d65..b2998b35 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -1272,6 +1272,11 @@ public static class ConfigOptionsPageController "ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme", }; + private static readonly int[] LandscapeDrawDistanceValues = + { + 3, 5, 8, 11, 15, 25, + }; + private static void BindRenderingQualitySection( UiTemplateListBox listBox, OptionPage page, @@ -1311,17 +1316,18 @@ public static class ConfigOptionsPageController storeOnly: true, // AP-198 resolveSprite, datFont, debugFont); - // UNRESOLVED (see class doc / register row): retail's own - // SetDefaultValue(8) does not index this 6-entry choice array. - // Reproduced as an opaque int; the menu simply shows no - // highlighted item at the default (no crash, no invented mapping). + // Retail's SetEnumChoices carries the six integer payloads + // {3,5,8,11,15,25}; captions are indices only in presentation. + // SetDefaultValue(8) therefore selects Medium, and @render radius + // writes the same preference value this row reads. BuildMenuRow( listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString, read: () => bindings.LoadDisplay().LandscapeDrawDistance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }), defaultValue: 8, - storeOnly: true, // AP-198 - resolveSprite, datFont, debugFont); + storeOnly: false, + resolveSprite, datFont, debugFont, + payloadValues: LandscapeDrawDistanceValues); BuildToggleRow( listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, @@ -1736,7 +1742,8 @@ public static class ConfigOptionsPageController bool storeOnly, Func? resolveSprite, UiDatFont? datFont, - BitmapFont? debugFont) + BitmapFont? debugFont, + IReadOnlyList? payloadValues = null) { UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); if (row is null) @@ -1768,7 +1775,13 @@ public static class ConfigOptionsPageController if (tooltip is not null) menu.TooltipText = tooltip; + if (payloadValues is not null && payloadValues.Count != choiceKeys.Length) + throw new ArgumentException( + "Menu payload count must match the choice count.", + nameof(payloadValues)); + string[] choiceLabels = new string[choiceKeys.Length]; + int[] choiceValues = new int[choiceKeys.Length]; var items = new UiMenu.MenuItem[choiceKeys.Length]; for (int i = 0; i < choiceKeys.Length; i++) { @@ -1779,7 +1792,8 @@ public static class ConfigOptionsPageController $"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' " + $"(for '{labelKey}') did not resolve — item renders with no caption " + "rather than invented English."); - items[i] = new UiMenu.MenuItem(choiceLabels[i], i); + choiceValues[i] = payloadValues?[i] ?? i; + items[i] = new UiMenu.MenuItem(choiceLabels[i], choiceValues[i]); } menu.Items = items; @@ -1788,7 +1802,8 @@ public static class ConfigOptionsPageController menu.ButtonLabelProvider = () => { int current = menu.Selected is int selected ? selected : initial; - return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty; + int choiceIndex = Array.IndexOf(choiceValues, current); + return choiceIndex >= 0 ? choiceLabels[choiceIndex] : string.Empty; }; var row_ = new IntOptionRow( diff --git a/src/AcDream.App/UI/Layout/CreditsUiController.cs b/src/AcDream.App/UI/Layout/CreditsUiController.cs new file mode 100644 index 00000000..f0790ad7 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CreditsUiController.cs @@ -0,0 +1,433 @@ +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +internal sealed record CreditsUiResources( + uint LayoutId, + ImportedLayout PictureLayout, + ImportedLayout TextLayout, + IReadOnlyList TextFragments, + IReadOnlyList PictureIds, + float SectionSeconds, + string PleaseWait); + +/// +/// Retained-mode port of retail gmCreditsUI @0x004E6E70..0x004E79E0. +/// Retail composes two selected roots from layout enum 0x10000004, +/// scrolls the localized ID_Credits1..N glyph block and a cyclic strip +/// of authored pictures by the same pixel delta, then returns to character +/// management on completion or any input action. +/// +internal sealed class CreditsUiController : IDisposable +{ + internal const uint RootEnum = 0x10000004u; + internal const uint PictureRootElementId = 0x10000413u; + internal const uint TextRootElementId = 0x10000410u; + internal const uint TextAreaElementId = 0x10000411u; + internal const uint DynamicPictureElementId = 0x10000415u; + + private readonly UiRoot _host; + private readonly ImportedLayout _pictureLayout; + private readonly ImportedLayout _textLayout; + private readonly UiText _textArea; + private readonly IReadOnlyList _textFragments; + private readonly IReadOnlyList _pictureIds; + private readonly float _sectionSeconds; + private readonly RetailDialogFactory _dialogs; + private readonly string _pleaseWait; + private readonly Func _nowSeconds; + private readonly Func _resolveSprite; + private readonly Action _returnToCharacterManagement; + private readonly CreditsActionSurface _actionSurface; + private readonly List _pictures = []; + private readonly Vector2 _authoredCanvas; + + private UiText.Line[] _lines = []; + private float _textHeight; + private double _startTime; + private double _duration; + private float _lastProgress; + private int _nextPicture; + private long _tickSequence; + private long _returnAtTick = long.MaxValue; + private uint _waitContext; + private bool _active; + private bool _returnPending; + private bool _disposed; + + private CreditsUiController( + UiRoot host, + ImportedLayout pictureLayout, + ImportedLayout textLayout, + UiText textArea, + IReadOnlyList textFragments, + IReadOnlyList pictureIds, + float sectionSeconds, + RetailDialogFactory dialogs, + string pleaseWait, + Func nowSeconds, + Func resolveSprite, + Action returnToCharacterManagement) + { + _host = host; + _pictureLayout = pictureLayout; + _textLayout = textLayout; + _textArea = textArea; + _textFragments = textFragments; + _pictureIds = pictureIds; + _sectionSeconds = sectionSeconds; + _dialogs = dialogs; + _pleaseWait = pleaseWait; + _nowSeconds = nowSeconds; + _resolveSprite = resolveSprite; + _returnToCharacterManagement = returnToCharacterManagement; + + float width = MathF.Max( + PictureRoot.Left + PictureRoot.Width, + TextRoot.Left + TextRoot.Width); + float height = MathF.Max( + PictureRoot.Top + PictureRoot.Height, + TextRoot.Top + TextRoot.Height); + _authoredCanvas = new Vector2( + width > 0f ? width : 800f, + height > 0f ? height : 600f); + + PictureRoot.Visible = false; + TextRoot.Visible = false; + _actionSurface = new CreditsActionSurface(BeginReturn) + { + Width = _authoredCanvas.X, + Height = _authoredCanvas.Y, + Visible = false, + ZOrder = int.MaxValue, + }; + } + + internal UiElement PictureRoot => _pictureLayout.Root; + internal UiElement TextRoot => _textLayout.Root; + internal UiText TextArea => _textArea; + internal IReadOnlyList Pictures => _pictures; + internal bool IsActive => _active; + internal double DurationSeconds => _duration; + + internal static CreditsUiController? CreateDetached( + UiRoot host, + CreditsUiResources resources, + RetailDialogFactory dialogs, + Func nowSeconds, + Func resolveSprite, + Action returnToCharacterManagement) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(resources); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(nowSeconds); + ArgumentNullException.ThrowIfNull(resolveSprite); + ArgumentNullException.ThrowIfNull(returnToCharacterManagement); + + if (resources.PictureLayout.Root.DatElementId != PictureRootElementId + || resources.TextLayout.Root.DatElementId != TextRootElementId + || resources.TextLayout.FindElement(TextAreaElementId) is not UiText textArea + || resources.TextFragments.Count == 0 + || resources.PictureIds.Count == 0 + || !float.IsFinite(resources.SectionSeconds) + || resources.SectionSeconds <= 0f) + { + Console.WriteLine( + "[UI] credits: authored root/text/picture contract is incomplete."); + return null; + } + + return new CreditsUiController( + host, + resources.PictureLayout, + resources.TextLayout, + textArea, + resources.TextFragments, + resources.PictureIds, + resources.SectionSeconds, + dialogs, + resources.PleaseWait, + nowSeconds, + resolveSprite, + returnToCharacterManagement); + } + + internal void Activate() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_active) + return; + + AttachRoots(); + ResetRun(); + _active = true; + PictureRoot.Visible = true; + TextRoot.Visible = true; + _actionSurface.Visible = true; + _host.DeclareFixedCanvas(this, _authoredCanvas); + _host.BringToFront(PictureRoot); + _host.BringToFront(TextRoot); + _host.BringToFront(_actionSurface); + _host.SetKeyboardFocus(_actionSurface); + + // Update order is retail's: ScrollText first, then ScrollPictures. + // At progress zero that creates the first picture one pixel below + // the picture field, exactly as CreateAndAddPicture @0x004E7592. + Tick(); + } + + internal void Tick() + { + if (_disposed || !_active) + return; + + _tickSequence++; + if (_returnPending) + { + if (_tickSequence >= _returnAtTick) + CompleteReturn(); + return; + } + + double elapsed = Math.Max(0d, _nowSeconds() - _startTime); + float progress = _duration <= 0d + ? 1f + : Math.Clamp((float)(elapsed / _duration), 0f, 1f); + // Timer::compute_time is monotonic in retail. Preserve that invariant + // even when a deterministic test clock is moved backwards. + progress = MathF.Max(progress, _lastProgress); + _lastProgress = progress; + + float fieldHeight = TextRoot.Height; + int oldTop = (int)MathF.Round(_textArea.Top); + int travel = (int)MathF.Round( + (fieldHeight + _textHeight) * progress, + MidpointRounding.ToEven); + int newTop = (int)MathF.Round(fieldHeight) - travel; + _textArea.Left = 0f; + _textArea.Top = newTop; + ScrollPictures(oldTop - newTop); + + if (progress >= 1f) + BeginReturn(); + } + + internal void ResetSession() + { + if (_disposed || !_active) + return; + Deactivate(); + _returnToCharacterManagement(); + } + + public void Dispose() + { + if (_disposed) + return; + bool restoreCharacterManagement = _active; + _disposed = true; + Deactivate(); + _host.RemoveChild(PictureRoot); + _host.RemoveChild(TextRoot); + _host.RemoveChild(_actionSurface); + if (restoreCharacterManagement) + _returnToCharacterManagement(); + } + + private void AttachRoots() + { + if (PictureRoot.Parent is null) + _host.AddChild(PictureRoot); + if (TextRoot.Parent is null) + _host.AddChild(TextRoot); + if (_actionSurface.Parent is null) + _host.AddChild(_actionSurface); + } + + private void ResetRun() + { + CloseWait(); + ClearPictures(); + _returnPending = false; + _returnAtTick = long.MaxValue; + _lastProgress = 0f; + _nextPicture = 0; + + _textArea.Width = TextRoot.Width; + float maximumWidth = Math.Max( + 1f, + _textArea.Width + - (_textArea.Padding + _textArea.MarginLeft) + - (_textArea.Padding + _textArea.MarginRight)); + Func measure = _textArea.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + string allText = string.Concat(_textFragments); + IReadOnlyList wrapped = UiText.WrapWords( + allText, + measure, + maximumWidth); + if (wrapped.Count == 0) + wrapped = [string.Empty]; + _lines = [.. wrapped.Select( + line => new UiText.Line(line, _textArea.DefaultColor))]; + _textArea.LinesProvider = () => _lines; + + float lineHeight = _textArea.DatFont?.LineHeight ?? 16f; + _textHeight = Math.Max(lineHeight, lineHeight * _lines.Length); + _textArea.Left = 0f; + _textArea.Top = TextRoot.Height; + _textArea.Height = _textHeight; + + // Initialize @0x004E726C. The loop cursor is N+1 when the first + // invalid ID_Credits key terminates enumeration, so preserve that + // exact denominator rather than substituting the valid count. + float terminatorIndex = _textFragments.Count + 1f; + _duration = _sectionSeconds + * (TextRoot.Height + _textHeight) + / (TextRoot.Height + _textHeight / terminatorIndex); + _startTime = _nowSeconds(); + } + + private void ScrollPictures(int deltaPixels) + { + if (deltaPixels != 0) + foreach (UiPanel picture in _pictures) + picture.Top -= deltaPixels; + + if (_pictures.Count > 0 + && _pictures[0].Top + _pictures[0].Height < 0f) + { + UiPanel expired = _pictures[0]; + _pictures.RemoveAt(0); + PictureRoot.RemoveChild(expired); + } + + if (_pictures.Count == 0) + AddPicture(); + + if (_pictures.Count > 0 + && _pictures[^1].Top < PictureRoot.Height) + { + AddPicture(); + } + } + + private void AddPicture() + { + if (_pictureIds.Count == 0) + return; + + uint pictureId = _pictureIds[_nextPicture]; + _nextPicture = (_nextPicture + 1) % _pictureIds.Count; + (uint texture, int width, int height) = _resolveSprite(pictureId); + if (texture == 0u || width <= 0 || height <= 0) + return; + + float top = _pictures.Count == 0 + ? PictureRoot.Height + 1f + : _pictures[^1].Top + _pictures[^1].Height + 1f; + var picture = new UiPanel + { + DatElementId = DynamicPictureElementId, + Left = 0f, + Top = top, + Width = width, + Height = height, + BackgroundColor = Vector4.Zero, + BorderColor = Vector4.Zero, + BorderThickness = 0f, + BackgroundSprite = pictureId, + SpriteResolve = _resolveSprite, + ClickThrough = true, + }; + PictureRoot.AddChild(picture); + _pictures.Add(picture); + } + + private void BeginReturn() + { + if (_disposed || !_active || _returnPending) + return; + + _returnPending = true; + _waitContext = _dialogs.MakeWait(_pleaseWait); + // QueueUIMode is asynchronous in retail. Keep the wait visible for one + // complete presented frame, then perform the local mode swap. + _returnAtTick = _tickSequence + 2; + } + + private void CompleteReturn() + { + if (!_active) + return; + Deactivate(); + _returnToCharacterManagement(); + } + + private void Deactivate() + { + _returnPending = false; + _returnAtTick = long.MaxValue; + _active = false; + PictureRoot.Visible = false; + TextRoot.Visible = false; + _actionSurface.Visible = false; + if (ReferenceEquals(_host.KeyboardFocus, _actionSurface)) + _host.SetKeyboardFocus(null); + _host.RevokeFixedCanvas(this); + CloseWait(); + ClearPictures(); + } + + private void CloseWait() + { + uint context = _waitContext; + _waitContext = 0u; + if (context != 0u) + _dialogs.CloseDialog(context); + } + + private void ClearPictures() + { + foreach (UiPanel picture in _pictures) + PictureRoot.RemoveChild(picture); + _pictures.Clear(); + } + + private sealed class CreditsActionSurface : UiElement + { + private readonly Action _onAction; + + public override bool HandlesClick => true; + + public CreditsActionSurface(Action onAction) + { + _onAction = onAction ?? throw new ArgumentNullException(nameof(onAction)); + AcceptsFocus = true; + ClickThrough = false; + } + + public override bool OnEvent(in UiEvent e) + { + if (!Enabled || !Visible) + return false; + if (e.Type is UiEventType.KeyDown + or UiEventType.MouseDown + or UiEventType.RightDown + or UiEventType.MiddleDown + or UiEventType.Scroll) + { + _onAction(); + return true; + } + return e.Type is UiEventType.KeyUp + or UiEventType.MouseUp + or UiEventType.RightUp + or UiEventType.MiddleUp + or UiEventType.Click + or UiEventType.RightClick; + } + } +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 5091ec3f..ef1f1b53 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -706,10 +706,11 @@ public static class DatWidgetFactory if (state.Properties.Values.TryGetValue(0x14u, out var justify) && justify.Kind == UiPropertyKind.Enum) { - align = justify.UnsignedValue switch + align = ElementReader.MapHorizontalJustification( + justify.UnsignedValue) switch { - 0u or 2u => UiMeterLabelAlign.Left, - 3u or 5u => UiMeterLabelAlign.Right, + HJustify.Left => UiMeterLabelAlign.Left, + HJustify.Right => UiMeterLabelAlign.Right, _ => UiMeterLabelAlign.Center, }; } @@ -893,12 +894,7 @@ public static class DatWidgetFactory // afterward will override these — this is only the dat-driven default. bool centered = info.HJustify == HJustify.Center; bool rightAligned = info.HJustify == HJustify.Right; - var vJustify = info.VJustify switch - { - VJustify.Top => VJustify.Top, - VJustify.Bottom => VJustify.Bottom, - _ => VJustify.Center, - }; + var vJustify = info.VJustify; var t = new UiText { diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index cd335cc6..08813b8c 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -14,7 +14,8 @@ public enum HJustify : byte { Left = 0, Center = 1, Right = 2 } /// /// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification). -/// Values: 2=Top, 4=Bottom; absent/other = Center. +/// Retail CalcJustification @ 0x00467260 treats 1 as Center, +/// 3/5 as Bottom, and every other value (including constructor default 4) as Top. /// public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 } @@ -109,18 +110,17 @@ public sealed class ElementInfo /// /// Horizontal text justification from dat Properties[0x14] - /// (EnumBaseProperty: 0=Left, 1=Center, 3/5=Right). - /// Default is to preserve existing behavior where - /// controllers set Centered=true and no property was read. + /// (EnumBaseProperty: 1=Center, 3/5=Right, all others=Left). + /// Retail's constructor default is raw 2, which resolves to Left. /// - public HJustify HJustify = HJustify.Center; + public HJustify HJustify = HJustify.Left; /// /// Vertical text justification from dat Properties[0x15] - /// (EnumBaseProperty: 2=Top, 4=Bottom; absent/other = Center). - /// Default is to preserve existing behavior. + /// (EnumBaseProperty: 1=Center, 3/5=Bottom, all others=Top). + /// Retail's constructor default is raw 4, which resolves to Top. /// - public VJustify VJustify = VJustify.Center; + public VJustify VJustify = VJustify.Top; /// /// Font color from dat Properties[0x1B] (ColorBaseProperty, ARGB bytes). @@ -251,13 +251,8 @@ public sealed class ElementInfo /// an authored true HIDES the element at construction. Populated the /// same way as / /// (recomputed fresh from the effective merged state every call), but this - /// is a PURE DATA ADDITION: the shared / - /// path does not act on it. 1,083 elements - /// author this flag client-wide (docs/ISSUES.md #408, its own separately- - /// gated general-honor item) — only screens that explicitly walk their own - /// mounted subtree and check this field may hide elements by it (see - /// CharacterCreationUiController's chargen-scoped honor, register - /// AP-230). + /// feeds the shared , which applies the retail + /// construction-time visibility write uniformly to every built widget. /// public bool Invisible; @@ -583,12 +578,11 @@ public static class ElementReader ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, - // HJustify/VJustify: derived wins when it carries an explicit non-Center value - // (the dat property was present and read); otherwise inherit the base prototype's value. - // Center is the default (= "not set by this element") so Center-derived never overrides - // a non-Center base — matching the FontDid "non-zero wins" convention. - HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify, - VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify, + // Presence, not a semantic enum value, decides inheritance. Center + // is a legitimate authored override; using it as an "unset" + // sentinel silently lost derived raw value 1. + HJustify = HasEffectiveEnum(derived, 0x14u) ? derived.HJustify : base_.HJustify, + VJustify = HasEffectiveEnum(derived, 0x15u) ? derived.VJustify : base_.VJustify, // FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base. // Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base. FontColor = derived.FontColor ?? base_.FontColor, @@ -650,23 +644,13 @@ public static class ElementReader if (info.TryGetEffectiveProperty(0x14u, out var horizontal) && horizontal.Kind == UiPropertyKind.Enum) { - info.HJustify = horizontal.UnsignedValue switch - { - 0u or 2u => HJustify.Left, - 3u or 5u => HJustify.Right, - _ => HJustify.Center, - }; + info.HJustify = MapHorizontalJustification(horizontal.UnsignedValue); } if (info.TryGetEffectiveProperty(0x15u, out var vertical) && vertical.Kind == UiPropertyKind.Enum) { - info.VJustify = vertical.UnsignedValue switch - { - 2u => VJustify.Top, - 4u => VJustify.Bottom, - _ => VJustify.Center, - }; + info.VJustify = MapVerticalJustification(vertical.UnsignedValue); } if (info.TryGetEffectiveProperty(0x1Bu, out var color)) @@ -820,6 +804,24 @@ public static class ElementReader info.MinHeight = minHeight; } + internal static HJustify MapHorizontalJustification(ulong raw) => raw switch + { + 1UL => HJustify.Center, + 3UL or 5UL => HJustify.Right, + _ => HJustify.Left, + }; + + internal static VJustify MapVerticalJustification(ulong raw) => raw switch + { + 1UL => VJustify.Center, + 3UL or 5UL => VJustify.Bottom, + _ => VJustify.Top, + }; + + private static bool HasEffectiveEnum(ElementInfo info, uint propertyId) => + info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value) + && value.Kind == UiPropertyKind.Enum; + private static List ReadTabTable(ElementInfo info) { var entries = new List(); diff --git a/src/AcDream.App/UI/Layout/HousePageController.cs b/src/AcDream.App/UI/Layout/HousePageController.cs index 5ea2613f..ba456d85 100644 --- a/src/AcDream.App/UI/Layout/HousePageController.cs +++ b/src/AcDream.App/UI/Layout/HousePageController.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Numerics; +using AcDream.Runtime.Gameplay; namespace AcDream.App.UI.Layout; @@ -31,15 +32,11 @@ namespace AcDream.App.UI.Layout; /// ParseUpdateRentTime/ParseUpdateRentPayment, /// GameEventWiring's four delegate holes, the outbound HouseQuery /// action). The House-tab ownership-text closer session (also 2026-08-17) -/// wired to the minimal RuntimeHouseState -/// owner and ported DisplayPurchaseTimeText @0x004a3110's expired -/// branch — a fresh houseless character's House tab shows the single -/// decomp-verified line "You may buy another house immediately.", -/// live-connected-gate-verified (screenshot + structural UI-tree dump -/// against the real +Acdream character on a local ACE server). The -/// other six Display* line builders DisplayHouseData calls -/// (owned-house-only content: buy/rent payments and times, location, -/// warning text) remain unported — ISSUES #413's surviving scope. The +/// wired to RuntimeHouseState; issue +/// #413 later completed all seven Display* builders for both +/// houseless and owned-house snapshots. +/// preserves retail's Normal/RentPaid/RentNotPaid font-palette index while +/// the original string callback remains a compatibility fallback. The /// night-round review (F2, 2026-08-17) moved the outbound HouseQuery send /// from a House-tab-open trigger to retail's real login-complete edge (see /// 's own doc), so by the time a player opens @@ -78,11 +75,15 @@ public sealed class HousePageController // always returns null (no resolver = no row), so Refresh silently // produced zero rows regardless of Lines — the gap this session // closes alongside the text composition itself. - Func? TemplateResolver = null); + Func? TemplateResolver = null, + // Issue #413: typed rows preserve retail's HousePanelTextColor + // palette index. The string-only callback remains for compatibility + // with standalone fixtures and older embedding callers. + Func>? PanelLines = null); private readonly UiTemplateListBox _listBox; private readonly Bindings _bindings; - private IReadOnlyList _lastLines = Array.Empty(); + private IReadOnlyList _lastLines = Array.Empty(); private HousePageController(UiTemplateListBox listBox, Bindings bindings) { @@ -104,7 +105,7 @@ public sealed class HousePageController listBox.TemplateResolver = bindings.TemplateResolver; var controller = new HousePageController(listBox, bindings); - controller.Refresh(bindings.Lines()); + controller.Refresh(controller.CurrentLines()); return controller; } @@ -113,22 +114,43 @@ public sealed class HousePageController /// other social-panel pages' revision-gated rebuild discipline). public void Tick() { - IReadOnlyList lines = _bindings.Lines(); + IReadOnlyList lines = CurrentLines(); if (lines.SequenceEqual(_lastLines)) return; Refresh(lines); } public void OnShown() => _bindings.OnShown?.Invoke(); - private void Refresh(IReadOnlyList lines) + private IReadOnlyList CurrentLines() + { + if (_bindings.PanelLines is { } styled) + return styled(); + + IReadOnlyList plain = _bindings.Lines(); + if (plain.Count == 0) + return Array.Empty(); + + var projected = new HousePanelLine[plain.Count]; + for (int i = 0; i < plain.Count; i++) + projected[i] = new HousePanelLine(plain[i], HousePanelTextColor.Normal); + return projected; + } + + private void Refresh(IReadOnlyList lines) { _lastLines = lines; _listBox.Flush(); - foreach (string line in lines) + foreach (HousePanelLine line in lines) { UiElement? row = _listBox.AddItemFromTemplateList(0); if (row is UiText text) - text.LinesProvider = () => [new UiText.Line(line, Vector4.One)]; + { + int colorIndex = (int)line.Color; + Vector4 color = colorIndex >= 0 && colorIndex < text.FontColorPalette.Count + ? text.FontColorPalette[colorIndex] + : text.DefaultColor; + text.LinesProvider = () => [new UiText.Line(line.Text, color)]; + } } } } diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 01f23454..d5a8f5e8 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -128,9 +128,13 @@ public static class LayoutImporter // #409: see the Build overload's own sourceLayoutDid doc comment. w.SourceLayoutDid = sourceLayoutDid; - // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own - // doc comment for why this does NOT set Visible here. + // #408: retail applies P0x3B through UIElement::OnSetAttribute case 8 + // for every element: Invisible=true means SetVisible(false). Keep the + // authored bit for diagnostics while making its initial behavior a + // property of the shared importer, not of individual screens. w.AuthoredInvisible = info.Invisible; + if (info.Invisible) + w.Visible = false; // #409: the six per-element tooltip properties, same pure-data- // passthrough shape as AuthoredInvisible above. TooltipText is the @@ -223,19 +227,6 @@ public static class LayoutImporter if (child.StateMedia.Count == 0) continue; var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid); if (cw is null) continue; - // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor - // of AuthoredInvisible, scoped to children reached through - // THIS carve-out only — e.g. the chat new-text indicator - // (0x1000048C, live-DAT-confirmed Invisible=true on every - // layout it appears in) would otherwise render as a phantom - // element retail never shows, now that this carve-out - // builds it as a real widget instead of silently dropping - // it. This is NOT the general client-wide honor (#408, - // 1,083 elements) — every OTHER AuthoredInvisible consumer - // stays data-only, acted on nowhere but chargen's own - // HideAuthoredInvisibleElements walk (register AP-230). - if (cw.AuthoredInvisible) - cw.Visible = false; w.AddChild(cw); } } @@ -395,9 +386,9 @@ public static class LayoutImporter /// (the character footer's three state-groups; the tab-page content areas) manage /// visibility purely at runtime via C++ controller code. Retail uses /// UIElement::SetState(stateId) on the parent to propagate state, then - /// C++ getters access the right sub-group by element id. All groups are shipped - /// as visible in the imported widget tree; the relevant controllers - /// () perform the initial show/hide. + /// C++ getters access the right sub-group by element id. Sibling groups which + /// do not author property 0x3B start visible; the relevant controllers + /// () perform their runtime show/hide. /// /// /// Optional per-element font resolver (see @@ -677,38 +668,6 @@ public static class LayoutImporter if (sd.Properties is not null) { - // HorizontalJustification (0x14): EnumBaseProperty. - // Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right, - // every other value (including constructor default 2)=Left. - // Only update if still at the default (Center); derived-wins handled in Merge. - if (info.HJustify == HJustify.Center - && sd.Properties.TryGetValue(0x14u, out var hRaw) - && hRaw is EnumBaseProperty hEnum) - { - info.HJustify = hEnum.Value switch - { - 0u or 2u => HJustify.Left, - 1u => HJustify.Center, - 3u => HJustify.Right, - 5u => HJustify.Right, - _ => HJustify.Left, - }; - } - - // VerticalJustification (0x15): EnumBaseProperty. - // Retail values: 2=Top, 4=Bottom; absent/other = Center. - if (info.VJustify == VJustify.Center - && sd.Properties.TryGetValue(0x15u, out var vRaw) - && vRaw is EnumBaseProperty vEnum) - { - info.VJustify = vEnum.Value switch - { - 2u => VJustify.Top, - 4u => VJustify.Bottom, - _ => VJustify.Center, - }; - } - // ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A). // Only read when not already set (first dat state wins; Merge propagates from base). if (info.FontColor is null diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 8f54e684..38b909d5 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -652,13 +652,8 @@ public sealed class RetailTooltipPresenter : IDisposable // elements author P0x3D" sweep only covered hover TARGETS, never // the popup skins' text children.) // (b) Tooltip text is LEFT-aligned: the text child authors no - // justification and retail's unauthored default is Left, while - // our importer's ElementInfo default is Center — the same - // wrong-default class as #410's VJustify finding. Point-fixed - // here (the chat transcript does the same); the client-wide - // default remains #410's scope. - text.Centered = false; - text.RightAligned = false; + // justification, so the shared importer supplies retail's raw-2 + // constructor default (Left). // (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on // the popup skins) participate exactly as InqSizewMargins does: diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 733cd3ff..aa7276e6 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -85,6 +85,7 @@ public class UiDatElement : UiElement, IUiDatStateful public bool TrySetRetailState(uint stateId) { + uint appliedStateId = stateId; UiStateInfo? selectedState = null; if (stateId == UiStateInfo.DirectStateId) { @@ -114,23 +115,15 @@ public class UiDatElement : UiElement, IUiDatStateful // Normal_rollover/Highlight media but NO 'Normal' state at // all, so the row's PassToChildren 'Normal' hover-leave // cascade landed here and the bars never cleared. Retail's - // state-0 arm cascades state 0 to children off the BASE - // descriptor's own PassToChildren (m_desc.m_bPassToChildren, - // @0x00464eca), and the per-state Invisible honor below stays - // scoped to NAMED authored states exactly as before (the #408 - // gate) — selectedState remains null on this path. + // state-0 arm applies and cascades the BASE descriptor. ActiveState = ""; - if (Info.States.TryGetValue( - UiStateInfo.DirectStateId, out UiStateInfo? baseState) - && baseState.PassToChildren) - { - foreach (UiElement child in Children) - if (child is IUiDatStateful stateful) - stateful.TrySetRetailState(UiStateInfo.DirectStateId); - } - return true; + appliedStateId = UiStateInfo.DirectStateId; + Info.States.TryGetValue(appliedStateId, out selectedState); + } + else + { + ActiveState = stateName; } - ActiveState = stateName; } // Per-state Invisible (dat property 0x3B): retail's SetState applies @@ -144,21 +137,11 @@ public class UiDatElement : UiElement, IUiDatStateful // Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on // rollover. // - // SCOPED TO NAMED STATES ONLY: a 0x3B authored in the unnamed - // DirectState is the CONSTRUCTION-time "authored invisible" class - // (1,083 elements client-wide — docs/ISSUES.md #408, its own - // separately-gated general-honor item; ElementReader.Invisible/GF-13 - // captures it and only chargen's scoped walk acts on it, register - // AP-230). Honoring it here would un-gate #408 through the back - // door: LayoutImporter.BuildWidget's post-children state reapply - // calls TrySetRetailState(DirectStateId) on every built widget, so - // a DirectState honor would hide all 1,083 at import (measured - // same-round: 10 combat-layout elements incl. 0x10000454 went - // un-hit-testable, breaking the spell-favorite drag tests). The - // NAMED-state flip below is a live visibility state machine that - // cannot work at all without the honor — that is this port's line. - if (stateId != UiStateInfo.DirectStateId - && selectedState is not null + // #408: DirectState is not a special exception. It reaches the same + // OnSetAttribute switch during construction and whenever retail falls + // back to state 0, so it must be able to restore authored visibility + // after a named state changed it. + if (selectedState is not null && selectedState.Properties.TryGetValue(0x3Bu, out var invisibleProp) && invisibleProp.Kind == UiPropertyKind.Bool) Visible = !invisibleProp.BoolValue; @@ -167,7 +150,7 @@ public class UiDatElement : UiElement, IUiDatStateful { foreach (UiElement child in Children) if (child is IUiDatStateful stateful) - stateful.TrySetRetailState(stateId); + stateful.TrySetRetailState(appliedStateId); } return true; } diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 247268f9..ca9fb3bd 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -169,8 +169,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// the absent authored sprite exactly rather than inventing one. /// /// - /// G5 correction (vendor gate finding): it is a SCROLLABLE single - /// column, not a 3-column grid. The F1 review's "column-major grid" + /// G5 correction (vendor gate finding): it is a single scrollable + /// ListBox, not a 3-column grid. The F1 review's "column-major grid" /// framing was wrong — a live-dat scan (tools/VendorLayoutScan, /// dump/resolved 0x21000043 0x1000034F) shows /// 0x1000034F has TWO children, not one: the ListBox @@ -183,15 +183,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// 0x06004C60/63/66, up button (element /// 0x10000072, retail-seated on top) 0x06004C6C/6D/6E, down button /// (element 0x10000071, retail-seated on the bottom) 0x06004C69/6A/6B, - /// track 0x06004C5F). With 18 authored categories and only 6 - /// visible rows, retail's actual rendering is a single scrolling column - /// (matching the user's reference screenshot: ~visible rows + scrollbar + - /// highlight — not our earlier 3-column x 6-row grid showing all 18 at - /// once). switches the popup to this - /// shape; keeps its existing meaning - /// as the authored visible-row count (still 6 — 108px ListBox height / - /// 18px row height, now interpreted as "rows before scrolling" instead - /// of "rows before wrapping to a new column"). Chat's own popup + /// track 0x06004C5F). The later named-retail trace resolves the + /// apparent fixed-six-row ambiguity: OpenVendor inserts only present + /// categories, UIElement_ListBox::UpdateLayout @0x0046e460 sums their + /// row heights, ResizeScrollableArea broadcasts message 0x32, + /// and UIElement_Menu::RecalculatePopupSize @0x0046caf0 resizes this + /// four-edge-docked popup to that content, uncapped. The sibling scrollbar's + /// installed-DAT property 0x79=true hides it when the resized content + /// fits. keeps the authored single-column + /// structure while and + /// port those two retail + /// behaviors. Chat's own popup /// (LayoutDesc 0x21000006, element 0x1000001C) has NO /// sibling scrollbar element and is unaffected — /// never sets Scrollable, so @@ -572,6 +574,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // scrollbar, not a column-major grid — see the class doc's "G5 // correction" paragraph above. _typeMenu.Scrollable = true; + _typeMenu.PopupSizeToContent = true; + _typeMenu.PopupScrollbarHideWhenDisabled = true; _typeMenu.ScrollbarWidth = TypeMenuScrollbarWidth; _typeMenu.ScrollButtonExtent = TypeMenuScrollButtonExtent; _typeMenu.ScrollTrackSprite = TypeMenuScrollTrackSprite; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 2683d01e..384b7f27 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -321,7 +321,8 @@ public sealed record SocialRuntimeBindings( /// /// Batch C (overnight hover/UI round, 2026-08-17): bindings for the /// two-tab Map/House panel. defaults to -/// "no house" and to empty when the caller doesn't +/// "no house" and / +/// to empty when the caller doesn't /// wire the House wire groundwork — the panel still mounts and the Map tab /// still works standalone. /// @@ -330,7 +331,8 @@ public sealed record MapHouseRuntimeBindings( Func PlayerCellId, Func? HousePosition = null, Func>? HouseLines = null, - Action? HouseShown = null); + Action? HouseShown = null, + Func>? HousePanelLines = null); /// /// Campaign QT slice QT5: what the Journal panel's Contracts page reads — @@ -559,6 +561,7 @@ public sealed class RetailUiRuntime : IDisposable private ProjectileDebugOverlayController? _projectileDebugOverlay; private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; + private CreditsUiController? _creditsController; private CharacterCreationUiMountCoordinator? _characterCreationMount; private PluginSidePanel? _pluginSidePanel; private IDisposable? _characterSheetSubscription; @@ -780,6 +783,7 @@ public sealed class RetailUiRuntime : IDisposable public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; + internal CreditsUiController? CreditsController => _creditsController; internal CharacterCreationUiController? CharacterCreationController => _characterCreationMount?.Controller; @@ -964,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); CharacterManagementController?.Tick(); + _creditsController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick(); DialogFactory?.Tick(); @@ -1359,6 +1364,7 @@ public sealed class RetailUiRuntime : IDisposable { try { + _creditsController?.ResetSession(); CharacterManagementController?.ResetSession(); DialogFactory?.Reset(); TooltipPresenter?.HideCurrent(); @@ -4173,7 +4179,8 @@ public sealed class RetailUiRuntime : IDisposable // Same generic template resolver the Map tab's town // hotspots use — see HousePageController.Bindings. // TemplateResolver's own doc for why reusing it is correct. - TemplateResolver: ResolveHotspotTemplate)); + TemplateResolver: ResolveHotspotTemplate, + PanelLines: mh.HousePanelLines)); Layout.MapHousePanelController? controller; lock (_bindings.Assets.DatLock) @@ -5099,7 +5106,194 @@ public sealed class RetailUiRuntime : IDisposable Host.Root, bindings with { RequestCreate = () => CharacterCreationController?.Open() }, EnsureDialogFactory, - LoadCharacterManagementResources); + LoadCharacterManagementResources, + OpenCredits); + } + + private void OpenCredits() + { + if (_disposed) + return; + + CharacterManagementUiController? characters = + CharacterManagementController; + if (characters is null) + return; + + if (_creditsController is null) + { + RetailDialogFactory? dialogs = EnsureDialogFactory(); + CreditsUiResources? resources = LoadCreditsResources(); + if (dialogs is null || resources is null) + return; + + _creditsController = CreditsUiController.CreateDetached( + Host.Root, + resources, + dialogs, + static () => System.Diagnostics.Stopwatch.GetTimestamp() + / (double)System.Diagnostics.Stopwatch.Frequency, + _bindings.Assets.ResolveSprite, + ReturnFromCredits); + if (_creditsController is null) + return; + } + + characters.SetPresentationSuppressed(true); + try + { + _creditsController.Activate(); + } + catch (Exception error) + { + characters.SetPresentationSuppressed(false); + Console.WriteLine( + $"[UI] credits activation failed: {error.Message}"); + } + } + + private void ReturnFromCredits() + => CharacterManagementController?.SetPresentationSuppressed(false); + + private CreditsUiResources? LoadCreditsResources() + { + uint layoutId; + ElementInfo? pictureInfo; + ElementInfo? textInfo; + ImportedLayout? pictureLayout; + ImportedLayout? textLayout; + var strings = new DatStringResolver(_bindings.Assets.Dats); + lock (_bindings.Assets.DatLock) + { + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + CreditsUiController.RootEnum, + 5u); + pictureInfo = layoutId == 0u + ? null + : LayoutImporter.ImportInfos( + _bindings.Assets.Dats, + layoutId, + CreditsUiController.PictureRootElementId); + textInfo = layoutId == 0u + ? null + : LayoutImporter.ImportInfos( + _bindings.Assets.Dats, + layoutId, + CreditsUiController.TextRootElementId); + pictureLayout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CreditsUiController.PictureRootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + textLayout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CreditsUiController.TextRootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + if (pictureInfo is null + || textInfo is null + || pictureLayout is null + || textLayout is null + || !TryGetCreditsDataId( + textInfo, + 0x10000002u, + out uint textAreaId) + || textAreaId != CreditsUiController.TextAreaElementId + || !TryGetCreditsDataId( + textInfo, + 0x10000003u, + out uint stringTableId) + || !textInfo.TryGetEffectiveFloat( + 0x10000004u, + out float sectionSeconds) + || !pictureInfo.TryGetEffectiveProperty( + 0x10000005u, + out UiPropertyValue pictureProperty) + || pictureProperty.Kind != UiPropertyKind.Array) + { + Console.WriteLine( + "[UI] credits: enum-table-5 properties could not be imported."); + return null; + } + + uint[] pictureIds = + [ + .. pictureProperty.ArrayValue + .Where(static value => value.Kind is + UiPropertyKind.DataId or UiPropertyKind.Enum) + .Select(static value => checked((uint)value.UnsignedValue)) + .Where(static value => value != 0u), + ]; + if (pictureIds.Length == 0) + return null; + + var textFragments = new List(); + string? pleaseWait; + lock (_bindings.Assets.DatLock) + { + for (int index = 1; index <= 4096; index++) + { + string? fragment = strings.Resolve( + stringTableId, + DatStringResolver.ComputeHash($"ID_Credits{index}")); + if (fragment is null) + break; + textFragments.Add(fragment); + } + + // MakePleaseWaitDialog @0x004E76F0 resolves table enum + // 0x10000001, the installed EoR table DID 0x23000001. + pleaseWait = strings.Resolve( + 0x23000001u, + DatStringResolver.ComputeHash("ID_Wait_PleaseWait")); + } + + if (textFragments.Count == 0 || pleaseWait is null) + { + Console.WriteLine( + "[UI] credits: localized credit/wait strings are unavailable."); + return null; + } + + Console.WriteLine( + $"[UI] retail credits ready (layout 0x{layoutId:X8}, " + + $"{textFragments.Count} text fragments, {pictureIds.Length} pictures). "); + return new CreditsUiResources( + layoutId, + pictureLayout, + textLayout, + textFragments, + pictureIds, + sectionSeconds, + pleaseWait); + } + + private static bool TryGetCreditsDataId( + ElementInfo info, + uint propertyId, + out uint value) + { + if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property) + && property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum + && property.UnsignedValue <= uint.MaxValue) + { + value = (uint)property.UnsignedValue; + return true; + } + + value = 0u; + return false; } private RetailDialogFactory? EnsureDialogFactory() @@ -5392,6 +5586,7 @@ public sealed class RetailUiRuntime : IDisposable () => _itemConfirmationController?.Dispose(), () => { + _creditsController?.Dispose(); _characterManagementMount?.Dispose(); _characterCreationMount?.Dispose(); _gameplayConfirmationController?.Dispose(); diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 0d4cc3d5..3d2783e4 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -385,11 +385,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight) { Selected = stateId == UiButtonStateMachine.Highlight; + ApplyStateVisibility(stateId); return true; } if (stateId == UiButtonStateMachine.Ghosted) { Enabled = false; + ApplyStateVisibility(stateId); return true; } if (!Enabled && stateId != UiButtonStateMachine.Ghosted) @@ -424,12 +426,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (!HasStateMedia("")) return false; ActiveState = ""; + ApplyStateVisibility(stateId); CascadeStateToChildren(stateId); return true; } if (TryFindState(stateId, out var state)) { ActiveState = state.Name; + ApplyStateVisibility(stateId); CascadeStateToChildren(stateId); return true; } @@ -439,12 +443,28 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName)) { ActiveState = stateName; + ApplyStateVisibility(stateId); CascadeStateToChildren(stateId); return true; } return false; } + /// + /// #408: UIElement::SetState applies the committed descriptor's properties + /// through UIElement::OnSetAttribute for buttons too. Property 0x3B is the + /// shared Invisible switch; it is not a generic-container-only behavior. + /// + private void ApplyStateVisibility(uint stateId) + { + if (_info.States.TryGetValue(stateId, out var state) + && state.Properties.Values.TryGetValue(0x3Bu, out var invisible) + && invisible.Kind == UiPropertyKind.Bool) + { + Visible = !invisible.BoolValue; + } + } + /// Merged for this element. /// Dat file-id → (GL texture handle, native px width, native px height). /// Returns (0,0,0) when the texture is not yet uploaded. diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index e0c3874f..17cfc809 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -59,14 +59,11 @@ public abstract class UiElement /// /// GF-13 (Campaign CC gate round 1, Batch A): mirrors - /// ElementInfo.Invisible (dat property 0x3B) — a PURE DATA - /// PASSTHROUGH set by LayoutImporter.BuildWidget at construction. - /// The shared importer does NOT act on this flag (1,083 elements author - /// it client-wide, docs/ISSUES.md #408); it exists only so a screen that - /// owns its own mounted subtree can honor it explicitly, the way - /// CharacterCreationUiController does for the chargen screen - /// (register AP-230). Reading this never changes by - /// itself. + /// ElementInfo.Invisible (dat property 0x3B), retained for + /// diagnostics after LayoutImporter.BuildWidget applies retail's + /// construction-time SetVisible(value == 0) behavior client-wide. + /// Runtime controllers may subsequently call just + /// as retail may issue a later SetVisible. /// public bool AuthoredInvisible { get; internal set; } diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index f2b7237b..d33df13b 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -85,21 +85,20 @@ public sealed class UiMenu : UiElement public float ColumnWidth { get; set; } = 191f; // dat item template W=191 /// - /// G5 (vendor gate finding): retail's authored vendor category popup + /// Retail's authored vendor category popup /// (LayoutDesc 0x21000043, root 0x1000034F) pairs its /// ListBox (element 0x10000350, type 0x5) with a SIBLING /// UIElement_Scrollbar (element 0x10000351, type 0xB, /// 16px wide, docked immediately right of the list at x=100) — verified - /// via a live-dat scan (tools/VendorLayoutScan) against - /// client_local_English.dat: the ListBox reads a single-column - /// shape (attributes resolving to m_nCols=1/m_nRows=6) and - /// the row template (0x10000352) is 100×18 — a SCROLLABLE single - /// column with 6 visible rows, not our earlier column-major grid - /// approximation (which showed all 18 categories at once across 3 - /// columns, never matching the retail screenshot's ~one-column-with- - /// scrollbar look). becomes the VISIBLE ROW - /// COUNT in this mode (still authored-driven — 108px ListBox height / 18px - /// row height = 6). Chat's own popup (LayoutDesc 0x21000006) has + /// via installed-DAT inspection against client_local_English.dat. + /// The authored 100x108 ListBox starts with a six-row viewport, but retail + /// UIElement_ListBox::UpdateLayout @0x0046e460 resizes its scrollable + /// content to the number of inserted categories. Because all four edges are + /// docked, message 0x32 reaches + /// UIElement_Menu::RecalculatePopupSize @0x0046caf0 and grows or + /// shrinks the popup to that content. The sibling scrollbar authors property + /// 0x79=true, so it disappears once the resized viewport fits the + /// content. Chat's own popup (LayoutDesc 0x21000006) has /// NO sibling scrollbar element and keeps the class default false — the /// legacy column-major grid path below is untouched for it. /// @@ -140,6 +139,24 @@ public sealed class UiMenu : UiElement public uint ScrollUpSprite { get; set; } public uint ScrollDownSprite { get; set; } + /// + /// Retail scrollbar property 0x79 for the popup's authored sibling + /// scrollbar. When the content fits and the scrollbar is therefore disabled, + /// hide the sibling completely: no chrome, pointer target, or reserved width. + /// Vendor popup element 0x10000351 authors this true. Retail owns a real + /// sibling widget and calls SetVisible(false) from + /// UIElement_Scrollbar::UpdateLayout @0x004710d0; this procedural popup + /// must also remove the sibling's width from its flattened geometry to produce + /// the same visible result. + /// + public bool PopupScrollbarHideWhenDisabled { get; set; } + + /// Presentation projection shared by drawing and pointer dispatch. + /// Internal for the same focused-test purpose as + /// . + internal bool IsPopupScrollbarPresentationVisible + => !PopupScrollbarHideWhenDisabled || PopupContentOverflows; + private bool _draggingPopupThumb; private float _popupThumbDragOffset; @@ -252,9 +269,8 @@ public sealed class UiMenu : UiElement /// popup ListBox (0x21000043/0x10000358) reads edges L=T=R=B=1 /// (menuprobe3, OptionsPanelLiveMountProbeTests), so /// sets - /// this true; chat's grid popup and vendor's shipped 6-row window keep - /// the class default false (vendor's authored ListBox is ALSO docked — - /// tracked as its own issue, not silently reworked here). + /// this true. Vendor's ListBox has the same four docked edges and therefore + /// enables it too; chat's grid popup keeps the class default false. /// When set, stops being the visible-window /// height and the popup shows every item with no scroll overflow. /// @@ -333,12 +349,14 @@ public sealed class UiMenu : UiElement // Interior = the row content; Outer = interior + the 8-piece bevel ring. // Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window, - // not a wrap threshold), widened by the docked scrollbar's own authored width. + // not a wrap threshold), widened by the docked scrollbar only while that + // sibling is presentation-visible. Retail property 0x79 removes a disabled + // scrollbar completely; keeping its width caused #386's empty placeholder. private int ColumnCount => Scrollable ? 1 : (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); private float InteriorW => Scrollable - ? ColumnWidth + ScrollbarWidth + ? ColumnWidth + EffectiveScrollbarWidth : ColumnCount * ColumnWidth; /// The popup's visible row count. Size-to-content (retail's @@ -352,6 +370,15 @@ public sealed class UiMenu : UiElement ? System.Math.Max(1, Items.Count) : RowsPerColumn; + /// UiMenu rows have a fixed authored height, so this is the same + /// overflow decision publishes to + /// , but is stable before the first draw/event has + /// configured that model. + private bool PopupContentOverflows => Items.Count > EffectiveVisibleRows; + + private float EffectiveScrollbarWidth + => IsPopupScrollbarPresentationVisible ? ScrollbarWidth : 0f; + private float InteriorH => EffectiveVisibleRows * RowHeight; private float OuterW => InteriorW + 2 * Border; private float OuterH => InteriorH + 2 * Border; @@ -362,6 +389,11 @@ public sealed class UiMenu : UiElement /// a full render pass. public float PopupOuterHeight => OuterH; + /// The popup's outer (bevel-inclusive) width. Exposed alongside + /// so the retail hide-disabled scrollbar rule + /// can be pinned without a GPU render harness. + public float PopupOuterWidth => OuterW; + /// /// G7 (vendor gate finding, item 2 — popup direction): port of retail /// UIElement_Menu::Open (pc:120210-120252, 0x0046cc30)'s Y placement: @@ -558,10 +590,11 @@ public sealed class UiMenu : UiElement /// /// G5: single-column popup with a docked scrollbar — port of the vendor category /// dropdown's authored shape (LayoutDesc 0x21000043, see 's - /// doc comment). Draws exactly rows (the authored visible - /// window), sliced from starting at , plus - /// the scrollbar chrome using the SAME thumb geometry itself - /// uses (). + /// doc comment). Draws rows, sliced from + /// starting at , plus the scrollbar + /// chrome when its authored disabled-presentation rule allows it. Thumb geometry + /// is the same helper itself uses + /// (). /// private void DrawScrollablePopup(UiRenderContext ctx, Func resolve) { @@ -602,9 +635,9 @@ public sealed class UiMenu : UiElement { int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight)); PopupScroll.LineHeight = lineHeight; - // Size-to-content: view == content, so HasOverflow is false and the - // scrollbar draws its chrome with no thumb (retail's authored - // scrollbar sibling stretches with the docked popup the same way). + // Size-to-content: view == content, so HasOverflow is false. Whether + // the disabled scrollbar remains visible is its authored 0x79 property, + // projected by IsPopupScrollbarPresentationVisible. PopupScroll.SetExtents(Items.Count * lineHeight, EffectiveVisibleRows * lineHeight); } @@ -625,6 +658,8 @@ public sealed class UiMenu : UiElement private void DrawPopupScrollbar( UiRenderContext ctx, Func resolve, float x, float y) { + if (!IsPopupScrollbarPresentationVisible) return; + DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH); float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH); @@ -801,7 +836,9 @@ public sealed class UiMenu : UiElement } float scrollbarX = ColumnWidth; - if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH) + if (IsPopupScrollbarPresentationVisible + && ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth + && iy >= 0 && iy < InteriorH) { ConfigurePopupScroll(); float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH); diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 71fc7202..a477a4a0 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -485,15 +485,13 @@ public sealed class UiText : UiElement, IUiDatStateful // Per-state Invisible (dat property 0x3B): retail's SetState applies the // committed state's properties through UIElement::OnSetAttribute, whose // case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is - // `SetVisible(value == 0)`. Same NAMED-states-only scoping as - // UiDatElement.TrySetRetailState (a DirectState 0x3B is the - // construction-time "authored invisible" class — #408, separately - // gated). First consumer here: the vitals cur/max number labels + // `SetVisible(value == 0)`. #408 includes DirectState: returning to + // state 0 must restore its authored visibility after a named state + // changed it. First consumer here: the vitals cur/max number labels // (0x100000EB/ED/EF) author HideDetail={0x3B:false} / // ShowDetail={0x3B:true} — the numbers hide when the click toggle // switches the window to the graphical icon mode. - if (stateId != UiStateInfo.DirectStateId - && state is not null + if (state is not null && state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp) && invisibleProp.Kind == UiPropertyKind.Bool) Visible = !invisibleProp.BoolValue; diff --git a/src/AcDream.App/World/InventoryWorldDropProjectionController.cs b/src/AcDream.App/World/InventoryWorldDropProjectionController.cs index 40db242e..3f671d28 100644 --- a/src/AcDream.App/World/InventoryWorldDropProjectionController.cs +++ b/src/AcDream.App/World/InventoryWorldDropProjectionController.cs @@ -2,6 +2,7 @@ using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; namespace AcDream.App.World; @@ -26,6 +27,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable private readonly ClientObjectTable _objects; private readonly LiveEntityRuntime _runtime; private readonly LiveEntityHydrationController _hydration; + private readonly SelectionState _selection; private readonly PendingSplitToWorldProjection _pending; private readonly Func _now; private bool _disposed; @@ -35,6 +37,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable ClientObjectTable objects, LiveEntityRuntime runtime, LiveEntityHydrationController hydration, + SelectionState selection, Func now) { _interaction = interaction @@ -43,6 +46,7 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _hydration = hydration ?? throw new ArgumentNullException(nameof(hydration)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _now = now ?? throw new ArgumentNullException(nameof(now)); _pending = new PendingSplitToWorldProjection(); @@ -64,7 +68,15 @@ internal sealed class InventoryWorldDropProjectionController : IDisposable // Consume before the synchronous CreateObject graph runs. Re-entrant // callbacks cannot bind the same split intent to a second GUID. _hydration.OnCreate(spawn); - return _runtime.TryGetSnapshot(update.Guid, out _); + bool recovered = _runtime.TryGetSnapshot(update.Guid, out _); + if (recovered) + { + // ACCWeenieObject::DeclareValid @0x0058E481 transfers the one + // global selection to the recognized split result. This is an + // automatic system transition, not a synthetic inventory click. + _selection.Select(update.Guid, SelectionChangeSource.System); + } + return recovered; } public void Dispose() diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 0b835795..69f55273 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1222,7 +1222,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource } if (record.FullCellId != token.ExactCellId || record.Canonical.PlacementCommitVersion - != token.PlacementCommitVersion) + != token.PlacementCommitVersion + || record.Canonical.PhysicsBody is not { } body + || body.Position != projection.WorldPosition + || body.Orientation != projection.Orientation) { // A newer move superseded this receipt's facts after the drain. // @@ -1264,13 +1267,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // That is a behaviour change in the wrong direction, not a // restoration. // - // The ONE supersession neither term covers is - // RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose: - // the far-snap Refused/Contention arm writes `body.Position` and - // `body.Orientation` with no placement commit and no cell move, so - // it can stale this receipt's pose silently. That is pre-existing - // (it predates C5b, which changed nothing about that arm) and is - // filed as docs/ISSUES.md #323 rather than papered over here. + // #323: compare the retained body's exact pose too. The receipt + // copied these floats directly from that body, so exact equality + // is the correct proof that its pose is still current. This + // catches StoreAcceptedDestinationPose's legitimate pose-only + // fallback without abusing PositionAuthorityVersion (which can + // advance while the receipt facts remain unchanged) or redefining + // PlacementCommitVersion for a store that committed no cell. return true; } diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 35ff578d..00e54a9e 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -60,6 +60,41 @@ public static class ClientCommandRequests public const uint AddPlayerPermissionOpcode = 0x0219u; public const uint RemovePlayerPermissionOpcode = 0x021Au; public const uint AbandonHouseOpcode = 0x021Fu; + public const uint QueryAllegianceNameOpcode = 0x0030u; + public const uint ClearAllegianceNameOpcode = 0x0031u; + public const uint SetAllegianceNameOpcode = 0x0033u; + public const uint SetAllegianceOfficerOpcode = 0x003Bu; + public const uint SetAllegianceOfficerTitleOpcode = 0x003Cu; + public const uint ListAllegianceOfficerTitlesOpcode = 0x003Du; + public const uint ClearAllegianceOfficerTitlesOpcode = 0x003Eu; + public const uint DoAllegianceLockActionOpcode = 0x003Fu; + public const uint SetAllegianceApprovedVassalOpcode = 0x0040u; + public const uint AllegianceChatGagOpcode = 0x0041u; + public const uint DoAllegianceHouseActionOpcode = 0x0042u; + public const uint AddPermanentGuestOpcode = 0x0245u; + public const uint RemovePermanentGuestOpcode = 0x0246u; + public const uint SetOpenHouseStatusOpcode = 0x0247u; + public const uint ChangeStoragePermissionOpcode = 0x0249u; + public const uint BootSpecificHouseGuestOpcode = 0x024Au; + public const uint RemoveAllStoragePermissionOpcode = 0x024Cu; + public const uint RequestFullGuestListOpcode = 0x024Du; + public const uint SetMotdOpcode = 0x0254u; + public const uint QueryMotdOpcode = 0x0255u; + public const uint ClearMotdOpcode = 0x0256u; + public const uint AddAllStoragePermissionOpcode = 0x025Cu; + public const uint RemoveAllPermanentGuestsOpcode = 0x025Eu; + public const uint BootEveryoneOpcode = 0x025Fu; + public const uint SetHooksVisibilityOpcode = 0x0266u; + public const uint ModifyAllegianceGuestPermissionOpcode = 0x0267u; + public const uint ModifyAllegianceStoragePermissionOpcode = 0x0268u; + public const uint BreakAllegianceBootOpcode = 0x0277u; + public const uint AllegianceChatBootOpcode = 0x02A0u; + public const uint AddAllegianceBanOpcode = 0x02A1u; + public const uint RemoveAllegianceBanOpcode = 0x02A2u; + public const uint ListAllegianceBansOpcode = 0x02A3u; + public const uint RemoveAllegianceOfficerOpcode = 0x02A5u; + public const uint ListAllegianceOfficersOpcode = 0x02A6u; + public const uint ClearAllegianceOfficersOpcode = 0x02A7u; // Batch C (Map/House toolbar panel, 2026-08-17): the query the House // tab needs to populate. ACE GameActionHouseQuery.cs: [GameAction( // GameActionType.HouseQuery)] (0x021E), Handle reads no payload and @@ -283,8 +318,9 @@ public static class ClientCommandRequests public static byte[] BuildRecallAllegianceHometown(uint sequence) => BuildParameterless(sequence, RecallAllegianceHometownOpcode); - // "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle: - // ReadString16L() player name (empty string = self). + // "@allegiance info " — GameActionAllegianceInfoRequest.Handle: + // ReadString16L() player name. Retail refuses an empty argument before + // constructing this request. public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) => BuildString(sequence, AllegianceInfoRequestOpcode, playerName); @@ -308,6 +344,133 @@ public static class ClientCommandRequests public static byte[] BuildAbandonHouse(uint sequence) => BuildParameterless(sequence, AbandonHouseOpcode); + // Complete named-retail @allegiance/@house administration family. + // Opcodes and field order are cross-checked against ACE's individual + // GameAction readers; the client-side grammar is ported separately by + // ClientCommandController from DoAllegiance/DoHouse and their helpers. + public static byte[] BuildBreakAllegianceBoot( + uint sequence, string playerName, bool accountBoot) => + BuildStringUInt32( + sequence, BreakAllegianceBootOpcode, playerName, accountBoot ? 1u : 0u); + + public static byte[] BuildAllegianceChatBoot( + uint sequence, string playerName, string reason) => + BuildTwoStrings(sequence, AllegianceChatBootOpcode, playerName, reason); + + public static byte[] BuildAllegianceChatGag( + uint sequence, string playerName, bool enabled) => + BuildStringUInt32( + sequence, AllegianceChatGagOpcode, playerName, enabled ? 1u : 0u); + + public static byte[] BuildAddAllegianceBan(uint sequence, string playerName) => + BuildString(sequence, AddAllegianceBanOpcode, playerName); + + public static byte[] BuildRemoveAllegianceBan(uint sequence, string playerName) => + BuildString(sequence, RemoveAllegianceBanOpcode, playerName); + + public static byte[] BuildListAllegianceBans(uint sequence) => + BuildParameterless(sequence, ListAllegianceBansOpcode); + + public static byte[] BuildSetAllegianceOfficer( + uint sequence, string playerName, uint officerLevel) => + BuildStringUInt32( + sequence, SetAllegianceOfficerOpcode, playerName, officerLevel); + + public static byte[] BuildRemoveAllegianceOfficer( + uint sequence, string playerName) => + BuildString(sequence, RemoveAllegianceOfficerOpcode, playerName); + + public static byte[] BuildListAllegianceOfficers(uint sequence) => + BuildParameterless(sequence, ListAllegianceOfficersOpcode); + + public static byte[] BuildClearAllegianceOfficers(uint sequence) => + BuildParameterless(sequence, ClearAllegianceOfficersOpcode); + + public static byte[] BuildSetAllegianceOfficerTitle( + uint sequence, uint officerLevel, string title) => + BuildUInt32String( + sequence, SetAllegianceOfficerTitleOpcode, officerLevel, title); + + public static byte[] BuildListAllegianceOfficerTitles(uint sequence) => + BuildParameterless(sequence, ListAllegianceOfficerTitlesOpcode); + + public static byte[] BuildClearAllegianceOfficerTitles(uint sequence) => + BuildParameterless(sequence, ClearAllegianceOfficerTitlesOpcode); + + public static byte[] BuildQueryAllegianceName(uint sequence) => + BuildParameterless(sequence, QueryAllegianceNameOpcode); + + public static byte[] BuildSetAllegianceName(uint sequence, string name) => + BuildString(sequence, SetAllegianceNameOpcode, name); + + public static byte[] BuildClearAllegianceName(uint sequence) => + BuildParameterless(sequence, ClearAllegianceNameOpcode); + + public static byte[] BuildAllegianceLockAction(uint sequence, uint action) => + BuildUInt32(sequence, DoAllegianceLockActionOpcode, action); + + public static byte[] BuildSetAllegianceApprovedVassal( + uint sequence, string playerName) => + BuildString(sequence, SetAllegianceApprovedVassalOpcode, playerName); + + public static byte[] BuildAllegianceHouseAction(uint sequence, uint action) => + BuildUInt32(sequence, DoAllegianceHouseActionOpcode, action); + + public static byte[] BuildQueryMotd(uint sequence) => + BuildParameterless(sequence, QueryMotdOpcode); + + public static byte[] BuildSetMotd(uint sequence, string motd) => + BuildString(sequence, SetMotdOpcode, motd); + + public static byte[] BuildClearMotd(uint sequence) => + BuildParameterless(sequence, ClearMotdOpcode); + + public static byte[] BuildSetOpenHouseStatus(uint sequence, bool isOpen) => + BuildUInt32(sequence, SetOpenHouseStatusOpcode, isOpen ? 1u : 0u); + + public static byte[] BuildAddPermanentGuest(uint sequence, string playerName) => + BuildString(sequence, AddPermanentGuestOpcode, playerName); + + public static byte[] BuildRemovePermanentGuest(uint sequence, string playerName) => + BuildString(sequence, RemovePermanentGuestOpcode, playerName); + + public static byte[] BuildRemoveAllPermanentGuests(uint sequence) => + BuildParameterless(sequence, RemoveAllPermanentGuestsOpcode); + + public static byte[] BuildChangeStoragePermission( + uint sequence, string playerName, bool enabled) => + BuildStringUInt32( + sequence, ChangeStoragePermissionOpcode, playerName, enabled ? 1u : 0u); + + public static byte[] BuildAddAllStoragePermission(uint sequence) => + BuildParameterless(sequence, AddAllStoragePermissionOpcode); + + public static byte[] BuildRemoveAllStoragePermission(uint sequence) => + BuildParameterless(sequence, RemoveAllStoragePermissionOpcode); + + public static byte[] BuildRequestFullGuestList(uint sequence) => + BuildParameterless(sequence, RequestFullGuestListOpcode); + + public static byte[] BuildBootSpecificHouseGuest( + uint sequence, string playerName) => + BuildString(sequence, BootSpecificHouseGuestOpcode, playerName); + + public static byte[] BuildBootEveryone(uint sequence) => + BuildParameterless(sequence, BootEveryoneOpcode); + + public static byte[] BuildSetHooksVisibility(uint sequence, bool visible) => + BuildUInt32(sequence, SetHooksVisibilityOpcode, visible ? 1u : 0u); + + public static byte[] BuildModifyAllegianceGuestPermission( + uint sequence, bool enabled) => + BuildUInt32( + sequence, ModifyAllegianceGuestPermissionOpcode, enabled ? 1u : 0u); + + public static byte[] BuildModifyAllegianceStoragePermission( + uint sequence, bool enabled) => + BuildUInt32( + sequence, ModifyAllegianceStoragePermissionOpcode, enabled ? 1u : 0u); + // Queries the local player's house info (owned house data, or a // no-house status) — GameActionHouseQuery.Handle: no payload read. public static byte[] BuildHouseQuery(uint sequence) => @@ -337,6 +500,39 @@ public static class ClientCommandRequests return body; } + private static byte[] BuildStringUInt32( + uint sequence, uint opcode, string value, uint number) + { + byte[] packed = PackString16L(value); + byte[] body = CreateBody(sequence, opcode, packed.Length + 4); + packed.CopyTo(body, 12); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(12 + packed.Length), number); + return body; + } + + private static byte[] BuildUInt32String( + uint sequence, uint opcode, uint number, string value) + { + byte[] packed = PackString16L(value); + byte[] body = CreateBody(sequence, opcode, 4 + packed.Length); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), number); + packed.CopyTo(body, 16); + return body; + } + + private static byte[] BuildTwoStrings( + uint sequence, uint opcode, string first, string second) + { + byte[] packedFirst = PackString16L(first); + byte[] packedSecond = PackString16L(second); + byte[] body = CreateBody( + sequence, opcode, packedFirst.Length + packedSecond.Length); + packedFirst.CopyTo(body, 12); + packedSecond.CopyTo(body, 12 + packedFirst.Length); + return body; + } + private static byte[] CreateBody(uint sequence, uint opcode, int payloadLength) { byte[] body = new byte[12 + payloadLength]; diff --git a/src/AcDream.Core.Net/Transport/AckNakScheduler.cs b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs index 1b46a51a..eefb1924 100644 --- a/src/AcDream.Core.Net/Transport/AckNakScheduler.cs +++ b/src/AcDream.Core.Net/Transport/AckNakScheduler.cs @@ -275,5 +275,6 @@ internal sealed class AckNakScheduler outboundIsaac: null); _send(datagram.Slice(0, datagramLength)); _stats.NaksSent++; + _stats.NakIdsSent += count; } } diff --git a/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs index 26c2ee87..e852c877 100644 --- a/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs +++ b/src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs @@ -263,6 +263,23 @@ internal sealed class OutboundFlowQueue : IDisposable { for (int i = 0; i < _pendingResends.Count; i++) { + // A later cumulative ACK can overtake an earlier NAK in the + // receive-owner queue before this frame reaches its sweep. + // Such a request is stale: the server has already advanced + // its ordered watermark past the requested packet. Replaying + // the old encrypted key is actively harmful against ACE, + // whose CRC/key search runs before duplicate-sequence + // rejection and can burn the remaining 256-word window. + // The equal case is NOT stale: ids[0] of an ordinary NAK is + // also its implicit ACK and still needs retransmission. + if (AckWatermark != 0 + && SequenceMath.IsNewer( + AckWatermark, + _pendingResends[i])) + { + continue; + } + // A pending id can leave the cache between NAK arrival and // this sweep only if a newer ack already covered it — the // server has it; serving nothing is correct. diff --git a/src/AcDream.Core.Net/Transport/ReliableTransport.cs b/src/AcDream.Core.Net/Transport/ReliableTransport.cs index 6fff835f..91f609ec 100644 --- a/src/AcDream.Core.Net/Transport/ReliableTransport.cs +++ b/src/AcDream.Core.Net/Transport/ReliableTransport.cs @@ -41,6 +41,9 @@ internal sealed class ReliableTransport : IDisposable public TransportStats Stats { get; } + /// Retail's 40-heartbeat packet-loss percentage. + public double PacketLossPercentage => _packetLoss.Percentage; + /// /// N6: retail's ephemeral-info flush cadence /// (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the x87 compare @@ -51,6 +54,7 @@ internal sealed class ReliableTransport : IDisposable public const double AssemblerSweepSeconds = 5.0; private readonly FragmentAssembler? _assembler; + private readonly RetailPacketLossAverager _packetLoss; private readonly long _assemblerSweepTicks; private long _assemblerSweepTimestamp; @@ -71,13 +75,19 @@ internal sealed class ReliableTransport : IDisposable _assemblerSweepTicks = (long)Math.Round(AssemblerSweepSeconds * Clock.Frequency); _assemblerSweepTimestamp = Clock.GetTimestamp(); + void CountedSend(ReadOnlySpan datagram) + { + send(datagram); + Stats.PacketsSent++; + } + Outbound = new OutboundFlowQueue( outboundIsaac, sessionClientId, sessionIteration, Clock, Stats, - send, + CountedSend, pool); Inbound = new InboundSequenceTracker(inboundIsaac, Stats); Scheduler = new AckNakScheduler( @@ -87,8 +97,9 @@ internal sealed class ReliableTransport : IDisposable sessionClientId, sessionIteration, Stats, - send); + CountedSend); Stats.CacheDepthSource = () => Outbound.CacheDepth; + _packetLoss = new RetailPacketLossAverager(Clock, Stats); } /// Last reliable sequence on the wire — the value unsequenced @@ -112,6 +123,12 @@ internal sealed class ReliableTransport : IDisposable { Clock.Update(); long now = Clock.GetTimestamp(); + // Retail snapshots the preceding two-second interval inside + // ClientNet::ProcessConnection before this heartbeat emits its + // ACK/NAK control traffic. Keep boundary control packets in the new + // sample rather than attributing them to the interval that just + // ended. + _packetLoss.Sweep(now, Stats); Scheduler.Sweep(now); Outbound.TransmitPendingResends(); diff --git a/src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs b/src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs new file mode 100644 index 00000000..c2e0fec7 --- /dev/null +++ b/src/AcDream.Core.Net/Transport/RetailPacketLossAverager.cs @@ -0,0 +1,124 @@ +namespace AcDream.Core.Net.Transport; + +/// +/// Retail's packet-loss telemetry window from +/// CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610 and +/// CLinkStatusAverages::AddSnapshot @ 0x00546650. +/// +/// +/// +/// ClientNet::ProcessConnection @ 0x00545450 snapshots the four +/// packet counters on its 2.0-second heartbeat. Each counter is a +/// CAverager<unsigned short,40>. The loss function divides the +/// windowed NAK + retransmit totals by received + sent totals and reports a +/// percentage. A zero denominator reports zero. +/// +/// +/// The input values here are cumulative acdream counters; each sample stores +/// their delta since the preceding heartbeat. The unchecked ushort conversion +/// preserves retail's snapshot-field width. +/// +/// +internal sealed class RetailPacketLossAverager +{ + public const int WindowSize = 40; + public const double SnapshotSeconds = 2.0; + + private readonly Sample[] _samples = new Sample[WindowSize]; + private readonly long _snapshotTicks; + private long _snapshotTimestamp; + private long _lastPacketsSent; + private long _lastRetransmitsSent; + private long _lastPacketsReceived; + private long _lastNakIdsSent; + private long _sentTotal; + private long _retransmitTotal; + private long _receivedTotal; + private long _nakTotal; + private int _next; + private int _count; + + public RetailPacketLossAverager(TransportClock clock, TransportStats stats) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(stats); + + _snapshotTicks = (long)Math.Round(SnapshotSeconds * clock.Frequency); + _snapshotTimestamp = clock.GetTimestamp(); + CaptureBaselines(stats); + } + + public double Percentage + { + get + { + long denominator = _receivedTotal + _sentTotal; + return denominator <= 0 + ? 0d + : 100d * (_nakTotal + _retransmitTotal) / denominator; + } + } + + public void Sweep(long now, TransportStats stats) + { + ArgumentNullException.ThrowIfNull(stats); + if (now - _snapshotTimestamp < _snapshotTicks) + return; + + // ProcessConnection records one snapshot when the heartbeat branch + // runs; it does not synthesize empty samples for skipped frames. + _snapshotTimestamp = now; + + var sample = new Sample( + DeltaAsRetailUShort(stats.PacketsSent, ref _lastPacketsSent), + DeltaAsRetailUShort(stats.ResendsSent, ref _lastRetransmitsSent), + DeltaAsRetailUShort(stats.PacketsReceived, ref _lastPacketsReceived), + DeltaAsRetailUShort(stats.NakIdsSent, ref _lastNakIdsSent)); + + if (_count == WindowSize) + Remove(_samples[_next]); + else + _count++; + + _samples[_next] = sample; + _next = (_next + 1) % WindowSize; + Add(sample); + } + + private void CaptureBaselines(TransportStats stats) + { + _lastPacketsSent = stats.PacketsSent; + _lastRetransmitsSent = stats.ResendsSent; + _lastPacketsReceived = stats.PacketsReceived; + _lastNakIdsSent = stats.NakIdsSent; + } + + private static ushort DeltaAsRetailUShort(long current, ref long previous) + { + long delta = current - previous; + previous = current; + return unchecked((ushort)Math.Max(0L, delta)); + } + + private void Add(Sample sample) + { + _sentTotal += sample.Sent; + _retransmitTotal += sample.Retransmitted; + _receivedTotal += sample.Received; + _nakTotal += sample.Naked; + } + + private void Remove(Sample sample) + { + _sentTotal -= sample.Sent; + _retransmitTotal -= sample.Retransmitted; + _receivedTotal -= sample.Received; + _nakTotal -= sample.Naked; + } + + private readonly record struct Sample( + ushort Sent, + ushort Retransmitted, + ushort Received, + ushort Naked); +} diff --git a/src/AcDream.Core.Net/Transport/TransportStats.cs b/src/AcDream.Core.Net/Transport/TransportStats.cs index 4b998af9..105f176e 100644 --- a/src/AcDream.Core.Net/Transport/TransportStats.cs +++ b/src/AcDream.Core.Net/Transport/TransportStats.cs @@ -9,6 +9,15 @@ namespace AcDream.Core.Net.Transport; /// internal sealed class TransportStats { + /// Checksum-valid post-negotiation datagrams received. Used by + /// retail's 2-second/40-sample link-status loss window. + public long PacketsReceived; + + /// Datagrams successfully emitted after transport negotiation. + /// Includes reliable traffic, retransmits, cumulative ACKs, and NAKs, + /// matching retail's packet-level denominator. + public long PacketsSent; + /// Datagrams re-emitted in response to a server NAK. public long ResendsSent; @@ -57,6 +66,11 @@ internal sealed class TransportStats /// ReceiverData::GetNaks @ 0x005490C0, ≤114 ids each). public long NaksSent; + /// Total missing sequence ids carried by emitted NAKs. Retail's + /// CLinkStatusSnapshot::nPktsNAKed counts missing packets, not the + /// number of control datagrams used to request them. + public long NakIdsSent; + /// N4: mis-parked keystream words reclaimed from validated /// cleartext RejectRetransmit sequences (the AD-51 ACE /// adaptation; always zero against a retail server). diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6bc9ea59..d2ede3e4 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -652,7 +652,8 @@ public sealed class WorldSession : IDisposable Volatile.Read(ref _lastInboundPacketTicks), Stopwatch.GetTimestamp(), Stopwatch.Frequency, - PingRoundTripSeconds); + PingRoundTripSeconds, + _transport?.PacketLossPercentage ?? 0d); internal double? PingRoundTripSeconds { @@ -1861,6 +1862,8 @@ public sealed class WorldSession : IDisposable // last-heard clock only for valid server traffic. Record at checksum // acceptance, before any heavy render-thread message handling. Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp()); + if (_transport is { } acceptedTransport) + acceptedTransport.Stats.PacketsReceived++; // N6: the first checksum-valid post-negotiation packet confirms the // server accepted our ConnectResponse and stops the handshake @@ -2814,13 +2817,234 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq)); } - /// Send retail "@allegiance info [name]" (0x027B). + /// Send retail "@allegiance info <name>" (0x027B). public void SendAllegianceInfoRequest(string playerName) { uint seq = NextGameActionSequence(); SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName)); } + public void SendBreakAllegianceBoot(string playerName, bool accountBoot) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildBreakAllegianceBoot( + seq, playerName, accountBoot)); + } + + public void SendAllegianceChatBoot(string playerName, string reason) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAllegianceChatBoot( + seq, playerName, reason)); + } + + public void SendAllegianceChatGag(string playerName, bool enabled) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAllegianceChatGag( + seq, playerName, enabled)); + } + + public void SendAddAllegianceBan(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAddAllegianceBan(seq, playerName)); + } + + public void SendRemoveAllegianceBan(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemoveAllegianceBan(seq, playerName)); + } + + public void SendListAllegianceBans() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildListAllegianceBans(seq)); + } + + public void SendSetAllegianceOfficer(string playerName, uint level) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficer( + seq, playerName, level)); + } + + public void SendRemoveAllegianceOfficer(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemoveAllegianceOfficer( + seq, playerName)); + } + + public void SendListAllegianceOfficers() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildListAllegianceOfficers(seq)); + } + + public void SendClearAllegianceOfficers() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficers(seq)); + } + + public void SendSetAllegianceOfficerTitle(uint level, string title) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetAllegianceOfficerTitle( + seq, level, title)); + } + + public void SendListAllegianceOfficerTitles() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildListAllegianceOfficerTitles(seq)); + } + + public void SendClearAllegianceOfficerTitles() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildClearAllegianceOfficerTitles(seq)); + } + + public void SendQueryAllegianceName() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildQueryAllegianceName(seq)); + } + + public void SendSetAllegianceName(string name) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetAllegianceName(seq, name)); + } + + public void SendClearAllegianceName() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildClearAllegianceName(seq)); + } + + public void SendAllegianceLockAction(uint action) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAllegianceLockAction(seq, action)); + } + + public void SendSetAllegianceApprovedVassal(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetAllegianceApprovedVassal( + seq, playerName)); + } + + public void SendAllegianceHouseAction(uint action) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAllegianceHouseAction(seq, action)); + } + + public void SendQueryMotd() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildQueryMotd(seq)); + } + + public void SendSetMotd(string motd) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetMotd(seq, motd)); + } + + public void SendClearMotd() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildClearMotd(seq)); + } + + public void SendSetOpenHouseStatus(bool isOpen) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetOpenHouseStatus(seq, isOpen)); + } + + public void SendAddPermanentGuest(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAddPermanentGuest(seq, playerName)); + } + + public void SendRemovePermanentGuest(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemovePermanentGuest(seq, playerName)); + } + + public void SendRemoveAllPermanentGuests() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemoveAllPermanentGuests(seq)); + } + + public void SendChangeStoragePermission(string playerName, bool enabled) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildChangeStoragePermission( + seq, playerName, enabled)); + } + + public void SendAddAllStoragePermission() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAddAllStoragePermission(seq)); + } + + public void SendRemoveAllStoragePermission() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemoveAllStoragePermission(seq)); + } + + public void SendRequestFullGuestList() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRequestFullGuestList(seq)); + } + + public void SendBootSpecificHouseGuest(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildBootSpecificHouseGuest( + seq, playerName)); + } + + public void SendBootEveryone() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildBootEveryone(seq)); + } + + public void SendSetHooksVisibility(bool visible) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSetHooksVisibility(seq, visible)); + } + + public void SendModifyAllegianceGuestPermission(bool enabled) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildModifyAllegianceGuestPermission( + seq, enabled)); + } + + public void SendModifyAllegianceStoragePermission(bool enabled) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildModifyAllegianceStoragePermission( + seq, enabled)); + } + // ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance // outbound wrappers. SocialActions/AllegianceRequests ship the byte // builders (repaired/added in FA1); this is the missing diff --git a/src/AcDream.Core/Audio/DatSoundCache.cs b/src/AcDream.Core/Audio/DatSoundCache.cs index a04190e4..22605c65 100644 --- a/src/AcDream.Core/Audio/DatSoundCache.cs +++ b/src/AcDream.Core/Audio/DatSoundCache.cs @@ -81,6 +81,7 @@ public sealed class DatSoundCache private readonly Dictionary> _waveEntries = new(); private readonly LinkedList _waveLru = new(); private readonly long _maxWaveBytes; + private readonly Action? _afterInitialWaveMiss; private long _residentWaveBytes; private long _hits; private long _misses; @@ -98,11 +99,24 @@ public sealed class DatSoundCache /// exercised deterministically without allocating tens of megabytes. /// public DatSoundCache(IDatObjectSource dats, long maxWaveBytes) + : this(dats, maxWaveBytes, afterInitialWaveMiss: null) + { + } + + /// + /// Deterministic concurrency seam used to park a caller after its fast + /// resident lookup but before the authoritative locked recheck. + /// + internal DatSoundCache( + IDatObjectSource dats, + long maxWaveBytes, + Action? afterInitialWaveMiss) { ArgumentNullException.ThrowIfNull(dats); ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1); _dats = dats; _maxWaveBytes = maxWaveBytes; + _afterInitialWaveMiss = afterInitialWaveMiss; } /// @@ -128,14 +142,37 @@ public sealed class DatSoundCache return null; } - Interlocked.Increment(ref _misses); + _afterInitialWaveMiss?.Invoke(waveId); - Lazy lazy = _inflight.GetOrAdd( - waveId, - static (id, self) => new Lazy( - () => self.DecodeUncached(id), - LazyThreadSafetyMode.ExecutionAndPublication), - this); + Lazy lazy; + lock (_gate) + { + // A caller can pause after the fast miss while another caller + // decodes, admits, and removes the in-flight Lazy. Recheck the + // authoritative caches and acquire/create the Lazy under the same + // gate used by admission so that stale caller cannot start a + // second decode after publication. + if (_waveEntries.TryGetValue(waveId, out var node)) + { + Interlocked.Increment(ref _hits); + Touch(node); + return node.Value.Wave; + } + + if (_negativeWaveIds.ContainsKey(waveId)) + { + Interlocked.Increment(ref _hits); + return null; + } + + Interlocked.Increment(ref _misses); + lazy = _inflight.GetOrAdd( + waveId, + static (id, self) => new Lazy( + () => self.DecodeUncached(id), + LazyThreadSafetyMode.ExecutionAndPublication), + this); + } try { WaveData? decoded = lazy.Value; diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 56d86d8f..aa8efedc 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -179,14 +179,28 @@ public sealed class ChatLog /// PlayerKilled (0x019E) — death announcement. /// - /// Death messages are routed as so they - /// share styling with other server announcements. The + /// Retail ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320 + /// suppresses this bystander-facing line when the local player is either + /// the victim or killer; those participants receive their dedicated + /// 0x01AC/0x01AD notification instead. Death messages that survive that + /// gate are routed as so they share styling + /// with other server announcements. The /// SenderGuid field carries the victim guid; the /// ChannelId field carries the killer guid (a small misuse /// of the field but avoids a schema change). /// - public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid) + public void OnPlayerKilled( + string deathMessage, + uint victimGuid, + uint killerGuid, + uint localPlayerGuid = 0u) { + if (localPlayerGuid != 0u + && (localPlayerGuid == victimGuid || localPlayerGuid == killerGuid)) + { + return; + } + Append(new ChatEntry( Kind: ChatKind.System, Sender: "", diff --git a/src/AcDream.Core/Physics/PhysicsTimestampGate.cs b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs index 0b9e5ad2..a919f72b 100644 --- a/src/AcDream.Core/Physics/PhysicsTimestampGate.cs +++ b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs @@ -192,11 +192,12 @@ public sealed class PhysicsTimestampGate _timestamps[ForcePosition] = forcePosition; // SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local - // FORCE_POSITION whose teleport is exactly equal blips immediately, - // assigns POSITION_TS directly (even equal/older), preserves the - // current heading, sends a position event, and returns WITHOUT - // advancing TELEPORT_TS. - if (teleport == _timestamps[Teleport]) + // FORCE_POSITION whose teleport stamp is not older (equal OR + // newer) blips immediately, assigns POSITION_TS directly (even + // equal/older), preserves the current heading, sends a position + // event, and returns WITHOUT advancing TELEPORT_TS. The same + // wrap-safe predicate is used by IsFreshTeleportStart above. + if (!IsNewer(teleport, _timestamps[Teleport])) { _timestamps[Position] = position; return PositionTimestampDisposition.ForcePosition; diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs index 788dfe8f..4bdc9d7d 100644 --- a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -16,26 +16,13 @@ namespace AcDream.Core.Physics; /// transitional blending. /// /// -/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as -/// an inline, App-layer-only implementation for the "legacy" (no -/// ) NPC idle-cycle path — -/// LiveEntityAnimationPresenter.Present's non-sequencer branch -/// (CurrFrame += legacyAdvanceSeconds * Framerate with the same -/// modulo wrap) and its private TryResolvePartFrame helper (the same -/// frame-bracket lerp/slerp). That call site has a live entity, a +/// This is the shared implementation used by both the "legacy" (no +/// ) NPC idle-cycle path and the chargen +/// preview. The live presenter has a live entity, a /// LiveEntityRuntime membership, and per-tick elapsed time supplied by /// the render loop; the chargen preview has none of that (there is no live -/// entity — character creation hasn't happened yet), so it cannot reuse that -/// class directly. Rather than re-typing the same formula a second time, -/// this Core, pure, unit-testable class is the shared primitive: the -/// chargen preview (AcDream.App.Rendering.ChargenPreviewAnimator) -/// consumes it directly, and it is safe for a future pass to redirect -/// LiveEntityAnimationPresenter's inline copy through it as a -/// behavior-preserving mechanical follow-up (not done here — that file is -/// live, heavily tested production entity-rendering code with zero relation -/// to this preview-only feature, so touching it is out of this slice's -/// blast radius by design, not oversight). Tracked as -/// docs/ISSUES.md #403 so the follow-up has an owner. +/// entity — character creation hasn't happened yet), so neither consumer can +/// own the primitive. Keeping it here prevents the two paths from drifting. /// /// public static class RetailAnimationCyclePlayback @@ -43,14 +30,15 @@ public static class RetailAnimationCyclePlayback /// /// Advances by elapsedSeconds * framerate /// and wraps it back into [lowFrame, highFrame] with the SAME modulo - /// shape LiveEntityAnimationPresenter.Present's legacy branch uses + /// shape retail playback and the live presenter's legacy branch use /// (over % (span + 1), not a plain clamp — a frame position that /// overshoots the end by more than one span wraps around more than once /// rather than sticking at the boundary, matching a long stall/resume). /// Returns unchanged for a degenerate cycle - /// ( <= ), a - /// non-positive , or a non-positive - /// . + /// ( <= ) or a + /// non-positive . A negative framerate + /// advances backward and clamps at the low frame, matching the former + /// live-presenter implementation exactly. /// public static float Advance( float currFrame, @@ -60,7 +48,7 @@ public static class RetailAnimationCyclePlayback float elapsedSeconds) { int span = highFrame - lowFrame; - if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f) + if (span <= 0 || elapsedSeconds <= 0f) return currFrame; float next = currFrame + elapsedSeconds * framerate; @@ -84,8 +72,7 @@ public static class RetailAnimationCyclePlayback /// back to ). Returns false — with /// default outputs — when is outside /// the bracketing frame's part list, matching - /// LiveEntityAnimationPresenter.TryResolvePartFrame's no- - /// sequence-frames branch exactly. + /// the live presenter's no-sequence-frames branch exactly. /// public static bool TryInterpolatePart( Animation animation, diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index f38e4a19..6f4ab17f 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -505,6 +505,15 @@ public sealed class WorldTimeService /// Current sky lighting state. public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction); + /// + /// Interpolate this clock's active day-group provider at an explicit day + /// fraction without changing the clock. Retail LScape::SetDay + /// uses this distinction: the sky continues advancing normally while + /// landscape lighting is sampled at noon. + /// + public SkyKeyframe SkyAtDayFraction(float dayFraction) => + _sky.Interpolate(dayFraction); + /// Convenience: current sun direction from derived sky state. public Vector3 CurrentSunDirection => SkyStateProvider.SunDirectionFromKeyframe(CurrentSky); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 46ab383f..9ff1ed74 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -4,6 +4,7 @@ using AcDream.Headless.Diagnostics; using AcDream.Headless.Plugins; using AcDream.Headless.Policies; using AcDream.Content.CharGen; +using AcDream.Core.Chat; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime; @@ -957,6 +958,16 @@ internal sealed class HeadlessSessionHost : IDisposable case ClientCommandId.QueryBirth: session.SendQueryBirth(); return; + case ClientCommandId.TogglePersistentDaylight: + { + bool enabled = !runtime.CharacterOwner.Options.GetOptionBit( + CharacterOptionId.PersistentAtDay); + _ = runtime.CharacterOwner.Options.TrySetOption( + (uint)CharacterOptionId.PersistentAtDay, + enabled, + session.SendSetSingleCharacterOption); + return; + } case ClientCommandId.Emote when !string.IsNullOrWhiteSpace(command.Arguments): session.SendEmote(command.Arguments.Trim()); @@ -1004,7 +1015,27 @@ internal sealed class HeadlessSessionHost : IDisposable session.SendRecallAllegianceHometown(); return; case ClientCommandId.AllegianceInfo: - session.SendAllegianceInfoRequest(command.Arguments.Trim()); + case ClientCommandId.AllegianceBoot: + case ClientCommandId.AllegianceBan: + case ClientCommandId.AllegianceChat: + case ClientCommandId.AllegianceBroadcast: + case ClientCommandId.AllegianceOfficer: + case ClientCommandId.AllegianceOfficerTitle: + case ClientCommandId.AllegianceName: + case ClientCommandId.AllegianceLock: + case ClientCommandId.AllegianceHouse: + case ClientCommandId.AllegianceMotd: + case ClientCommandId.AllegianceUnrecognizedSubcommand: + case ClientCommandId.HouseOpenStatus: + case ClientCommandId.HouseStorage: + case ClientCommandId.HouseBoot: + case ClientCommandId.HouseBootAll: + case ClientCommandId.HouseGuests: + case ClientCommandId.HouseHooks: + case ClientCommandId.HouseUnrecognizedSubcommand: + _ = CreateAdministrationDispatcher(session, runtime).TryExecute( + command.Command, + command.Arguments); return; case ClientCommandId.Permit: ExecutePermit(session, command.Arguments); @@ -1077,6 +1108,61 @@ internal sealed class HeadlessSessionHost : IDisposable } } + private static RetailAdministrationCommandDispatcher + CreateAdministrationDispatcher( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime) => new( + new RetailAdministrationCommandDispatcher.FeedbackBindings( + ShowSystemMessage: text => runtime.CommunicationOwner.AddText( + text, RetailLogTextType.Default), + ShowClientLocalMessage: text => runtime.CommunicationOwner.AddText( + text, RetailLogTextType.ClientLocal), + SetSingleCharacterOption: (optionId, enabled) => + _ = runtime.CharacterOwner.Options.TrySetOption( + optionId, + enabled, + session.SendSetSingleCharacterOption), + RequestAllegianceInfo: session.SendAllegianceInfoRequest), + new RetailAdministrationCommandDispatcher.ActionBindings( + BreakAllegianceBoot: session.SendBreakAllegianceBoot, + AllegianceChatBoot: session.SendAllegianceChatBoot, + AllegianceChatGag: session.SendAllegianceChatGag, + AllegianceBroadcast: text => session.SendChannel(0x02000000u, text), + ListAllegianceBans: session.SendListAllegianceBans, + AddAllegianceBan: session.SendAddAllegianceBan, + RemoveAllegianceBan: session.SendRemoveAllegianceBan, + ListAllegianceOfficers: session.SendListAllegianceOfficers, + ClearAllegianceOfficers: session.SendClearAllegianceOfficers, + SetAllegianceOfficer: session.SendSetAllegianceOfficer, + RemoveAllegianceOfficer: session.SendRemoveAllegianceOfficer, + ListAllegianceOfficerTitles: session.SendListAllegianceOfficerTitles, + ClearAllegianceOfficerTitles: session.SendClearAllegianceOfficerTitles, + SetAllegianceOfficerTitle: session.SendSetAllegianceOfficerTitle, + QueryAllegianceName: session.SendQueryAllegianceName, + SetAllegianceName: session.SendSetAllegianceName, + ClearAllegianceName: session.SendClearAllegianceName, + AllegianceLockAction: session.SendAllegianceLockAction, + SetAllegianceApprovedVassal: session.SendSetAllegianceApprovedVassal, + AllegianceHouseAction: session.SendAllegianceHouseAction, + QueryMotd: session.SendQueryMotd, + SetMotd: session.SendSetMotd, + ClearMotd: session.SendClearMotd, + SetOpenHouseStatus: session.SendSetOpenHouseStatus, + AddPermanentGuest: session.SendAddPermanentGuest, + RemovePermanentGuest: session.SendRemovePermanentGuest, + RemoveAllPermanentGuests: session.SendRemoveAllPermanentGuests, + ChangeStoragePermission: session.SendChangeStoragePermission, + AddAllStoragePermission: session.SendAddAllStoragePermission, + RemoveAllStoragePermission: session.SendRemoveAllStoragePermission, + RequestFullGuestList: session.SendRequestFullGuestList, + BootSpecificHouseGuest: session.SendBootSpecificHouseGuest, + BootEveryone: session.SendBootEveryone, + SetHooksVisibility: session.SendSetHooksVisibility, + ModifyAllegianceGuestPermission: + session.SendModifyAllegianceGuestPermission, + ModifyAllegianceStoragePermission: + session.SendModifyAllegianceStoragePermission)); + private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { @@ -1259,7 +1345,9 @@ internal sealed class HeadlessSessionHost : IDisposable (text, type) => Runtime.CommunicationOwner.AddText(text, type), Fellowship: Runtime.FellowshipOwner, Allegiance: Runtime.AllegianceOwner, - Contracts: Runtime.ContractsOwner)); + House: Runtime.HouseOwner, + Contracts: Runtime.ContractsOwner, + PlayerGuid: () => Runtime.PlayerIdentity.ServerGuid)); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, diff --git a/src/AcDream.Runtime/Chat/ClientCommandId.cs b/src/AcDream.Runtime/Chat/ClientCommandId.cs index e6ca5f7b..95e72ed2 100644 --- a/src/AcDream.Runtime/Chat/ClientCommandId.cs +++ b/src/AcDream.Runtime/Chat/ClientCommandId.cs @@ -18,6 +18,10 @@ public enum ClientCommandId QueryAge, QueryBirth, ToggleFrameRate, + /// @day — toggle retail's persistent noon landscape lighting. + TogglePersistentDaylight, + /// @render — modify retail's landscape radius or field of view. + RenderOption, ToggleUiLock, ShowVersion, ShowLocation, @@ -84,18 +88,52 @@ public enum ClientCommandId OffChannel, /// @alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone. AllegianceHometown, - /// "@allegiance info [name]" — request allegiance member info. + /// "@allegiance info <name>" — request allegiance member info. AllegianceInfo, + /// "@allegiance boot [-account] <name>". + AllegianceBoot, + /// "@allegiance ban ..." administration dispatcher. + AllegianceBan, + /// "@allegiance chat ..." administration dispatcher. + AllegianceChat, + /// "@allegiance broadcast <text>". + AllegianceBroadcast, + /// "@allegiance officer ..." administration dispatcher. + AllegianceOfficer, + /// "@allegiance title ..." officer-title dispatcher. + AllegianceOfficerTitle, + /// "@allegiance name ..." dispatcher. + AllegianceName, + /// "@allegiance lock ..." dispatcher. + AllegianceLock, + /// "@allegiance house ..." allegiance-house dispatcher. + AllegianceHouse, + /// "@allegiance motd ..." and standalone "@motd ...". + AllegianceMotd, /// "@house abandon" — abandon the character's house. HouseAbandon, + /// "@house open|close". + HouseOpenStatus, + /// "@house storage ...". + HouseStorage, + /// "@house remove|boot ...". + HouseBoot, + /// "@house boot_all|remove_all". + HouseBootAll, + /// "@house guest ...". + HouseGuests, + /// "@house hooks on|off". + HouseHooks, /// - /// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with - /// any subcommand beyond the 2 ported ones (info, hometown/ho). Never - /// dispatched — - /// is always - /// false for this id, so ChatCommandRouter shows retail's own - /// "Please see @help Allegiance..." refusal and never publishes an - /// ExecuteClientCommandCmd. + /// A genuinely unrecognized or incomplete @allegiance subcommand. + /// The catalog claims it locally and emits retail's Allegiance help + /// refusal rather than allowing channel fallback or server passthrough. /// AllegianceUnrecognizedSubcommand, + + /// + /// An unrecognized or incomplete @house subcommand. Like retail, + /// this is claimed locally and prints the House help refusal. + /// + HouseUnrecognizedSubcommand, } diff --git a/src/AcDream.Runtime/Chat/RetailAdministrationCommandDispatcher.cs b/src/AcDream.Runtime/Chat/RetailAdministrationCommandDispatcher.cs new file mode 100644 index 00000000..b06cb84a --- /dev/null +++ b/src/AcDream.Runtime/Chat/RetailAdministrationCommandDispatcher.cs @@ -0,0 +1,597 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Chat; + +/// +/// Presentation-independent port of retail's nested allegiance and house +/// command handlers. Both graphical and headless hosts use this one parser so +/// their argument grammar, local refusals, and wire effects cannot drift. +/// +/// +/// Ported from ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0, +/// DoHouse @ 0x00580860, and their named helper functions in the +/// September 2013 retail decompile. The action bindings name ACE's matching +/// GameAction readers; no raw opcode is exposed at this layer. +/// +public sealed class RetailAdministrationCommandDispatcher +{ + public sealed record FeedbackBindings( + Action ShowSystemMessage, + Action ShowClientLocalMessage, + Action SetSingleCharacterOption, + Action RequestAllegianceInfo); + + public sealed record ActionBindings( + Action BreakAllegianceBoot, + Action AllegianceChatBoot, + Action AllegianceChatGag, + Action AllegianceBroadcast, + Action ListAllegianceBans, + Action AddAllegianceBan, + Action RemoveAllegianceBan, + Action ListAllegianceOfficers, + Action ClearAllegianceOfficers, + Action SetAllegianceOfficer, + Action RemoveAllegianceOfficer, + Action ListAllegianceOfficerTitles, + Action ClearAllegianceOfficerTitles, + Action SetAllegianceOfficerTitle, + Action QueryAllegianceName, + Action SetAllegianceName, + Action ClearAllegianceName, + Action AllegianceLockAction, + Action SetAllegianceApprovedVassal, + Action AllegianceHouseAction, + Action QueryMotd, + Action SetMotd, + Action ClearMotd, + Action SetOpenHouseStatus, + Action AddPermanentGuest, + Action RemovePermanentGuest, + Action RemoveAllPermanentGuests, + Action ChangeStoragePermission, + Action AddAllStoragePermission, + Action RemoveAllStoragePermission, + Action RequestFullGuestList, + Action BootSpecificHouseGuest, + Action BootEveryone, + Action SetHooksVisibility, + Action ModifyAllegianceGuestPermission, + Action ModifyAllegianceStoragePermission); + + private readonly FeedbackBindings _feedback; + private readonly ActionBindings _actions; + + public RetailAdministrationCommandDispatcher( + FeedbackBindings feedback, + ActionBindings actions) + { + _feedback = feedback ?? throw new ArgumentNullException(nameof(feedback)); + _actions = actions ?? throw new ArgumentNullException(nameof(actions)); + } + + /// + /// Executes one typed management command. Returns false only when the id + /// is outside this dispatcher's family. + /// + public bool TryExecute(ClientCommandId command, string arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + switch (command) + { + case ClientCommandId.AllegianceInfo: + ExecuteAllegianceInfo(arguments); + return true; + case ClientCommandId.AllegianceBoot: + ExecuteAllegianceBoot(arguments); + return true; + case ClientCommandId.AllegianceBan: + ExecuteAllegianceBan(arguments); + return true; + case ClientCommandId.AllegianceChat: + ExecuteAllegianceChat(arguments); + return true; + case ClientCommandId.AllegianceBroadcast: + ExecuteAllegianceBroadcast(arguments); + return true; + case ClientCommandId.AllegianceOfficer: + ExecuteAllegianceOfficer(arguments); + return true; + case ClientCommandId.AllegianceOfficerTitle: + ExecuteAllegianceOfficerTitle(arguments); + return true; + case ClientCommandId.AllegianceName: + ExecuteAllegianceName(arguments); + return true; + case ClientCommandId.AllegianceLock: + ExecuteAllegianceLock(arguments); + return true; + case ClientCommandId.AllegianceHouse: + ExecuteAllegianceHouse(arguments); + return true; + case ClientCommandId.AllegianceMotd: + ExecuteAllegianceMotd(arguments); + return true; + case ClientCommandId.AllegianceUnrecognizedSubcommand: + ShowAllegianceHelpRefusal(); + return true; + case ClientCommandId.HouseOpenStatus: + _actions.SetOpenHouseStatus( + arguments.Equals("open", StringComparison.OrdinalIgnoreCase)); + return true; + case ClientCommandId.HouseStorage: + ExecuteHouseStorage(arguments); + return true; + case ClientCommandId.HouseBoot: + ExecuteHouseBoot(arguments); + return true; + case ClientCommandId.HouseBootAll: + _actions.BootEveryone(); + return true; + case ClientCommandId.HouseGuests: + ExecuteHouseGuests(arguments); + return true; + case ClientCommandId.HouseHooks: + ExecuteHouseHooks(arguments); + return true; + case ClientCommandId.HouseUnrecognizedSubcommand: + ShowHouseHelpRefusal(); + return true; + default: + return false; + } + } + + private void ExecuteAllegianceInfo(string arguments) + { + string name = arguments.Trim(); + if (name.Length == 0) + ShowClientLocal("Please specify an actual name."); + else + _feedback.RequestAllegianceInfo(name); + } + + private void ExecuteAllegianceBoot(string arguments) + { + string name = arguments.Trim(); + if (name.Length == 0) + { + ShowClientLocal("Please specify an actual name."); + return; + } + + int accountFlag = name.IndexOf("-account", StringComparison.OrdinalIgnoreCase); + bool accountBoot = accountFlag >= 0; + if (accountBoot) + name = name.Remove(accountFlag, "-account".Length).Trim(); + + _feedback.ShowSystemMessage( + $"Attempting to boot {name}{(accountBoot ? " (Account)" : string.Empty)}..."); + _actions.BreakAllegianceBoot(name, accountBoot); + } + + private void ExecuteAllegianceBan(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Equals("list", StringComparison.OrdinalIgnoreCase)) + { + _actions.ListAllegianceBans(); + return; + } + + string name = RemainderAfterFirstArgument(arguments).Trim(); + if (name.Length == 0) + { + ShowClientLocal("Please specify an actual name."); + return; + } + + if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)) + _actions.AddAllegianceBan(name); + else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase)) + _actions.RemoveAllegianceBan(name); + else + ShowAllegianceHelpRefusal(); + } + + private void ExecuteAllegianceChat(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Equals("on", StringComparison.OrdinalIgnoreCase) + || operation.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + _feedback.SetSingleCharacterOption( + (uint)CharacterOptionId.ListenToAllegianceChat, + operation.Equals("on", StringComparison.OrdinalIgnoreCase)); + return; + } + + string remainder = RemainderAfterFirstArgument(arguments).Trim(); + if (operation.Equals("kick", StringComparison.OrdinalIgnoreCase)) + { + int comma = remainder.IndexOf(','); + string name = comma < 0 ? remainder : remainder[..comma].Trim(); + string reason = comma < 0 + ? "No reason given." + : remainder[(comma + 1)..].Trim(); + _actions.AllegianceChatBoot(name, reason); + return; + } + + bool gag = operation.Equals("gag", StringComparison.OrdinalIgnoreCase); + bool ungag = operation.Equals("ungag", StringComparison.OrdinalIgnoreCase); + if (!gag && !ungag) + { + ShowAllegianceHelpRefusal(); + return; + } + + if (remainder.Length == 0) + { + ShowClientLocal("Please specify an actual name."); + return; + } + + _actions.AllegianceChatGag(remainder, gag); + } + + private void ExecuteAllegianceBroadcast(string arguments) + { + string message = arguments.Trim(); + if (message.Length == 0) + ShowAllegianceHelpRefusal(); + else + _actions.AllegianceBroadcast(message); + } + + private void ExecuteAllegianceOfficer(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Length == 0 + || operation.Equals("list", StringComparison.OrdinalIgnoreCase)) + { + _actions.ListAllegianceOfficers(); + return; + } + + if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + _actions.ClearAllegianceOfficers(); + return; + } + + string remainder = RemainderAfterFirstArgument(arguments); + if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase)) + { + string name = remainder.Trim(); + if (name.Length == 0) + ShowClientLocal("Please specify the name of an allegiance member."); + else + _actions.RemoveAllegianceOfficer(name); + return; + } + + if (!operation.Equals("add", StringComparison.OrdinalIgnoreCase) + && !operation.Equals("set", StringComparison.OrdinalIgnoreCase)) + { + ShowAllegianceHelpRefusal(); + return; + } + + string levelText = FirstArgument(remainder); + int level = RetailStrtolBaseZero(levelText); + if (level is < 1 or > 3) + { + ShowClientLocal( + "Please specify a valid officer level as a number between 1 and 3. " + + "Check the game help files for more information on officer levels."); + return; + } + + string officerName = RemainderAfterFirstArgument(remainder).Trim(); + if (officerName.Length == 0) + { + ShowClientLocal("Please specify the name of an allegiance member."); + return; + } + + _actions.SetAllegianceOfficer(officerName, (uint)level); + } + + private void ExecuteAllegianceOfficerTitle(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Length == 0 + || operation.Equals("list", StringComparison.OrdinalIgnoreCase)) + { + _actions.ListAllegianceOfficerTitles(); + return; + } + + if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + _actions.ClearAllegianceOfficerTitles(); + return; + } + + if (!operation.Equals("set", StringComparison.OrdinalIgnoreCase)) + { + ShowAllegianceHelpRefusal(); + return; + } + + string remainder = RemainderAfterFirstArgument(arguments); + string levelText = FirstArgument(remainder); + int level = RetailStrtolBaseZero(levelText); + if (level is < 1 or > 3) + { + ShowClientLocal( + "Please specify a valid officer level as a number between 1 and 3."); + return; + } + + string title = RemainderAfterFirstArgument(remainder).Trim(); + _actions.SetAllegianceOfficerTitle((uint)level, title); + } + + private void ExecuteAllegianceName(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Length == 0) + _actions.QueryAllegianceName(); + else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase)) + _actions.SetAllegianceName(RemainderAfterFirstArgument(arguments).Trim()); + else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase)) + _actions.ClearAllegianceName(); + else + ShowAllegianceHelpRefusal(); + } + + private void ExecuteAllegianceLock(string arguments) + { + string operation = FirstArgument(arguments); + uint? action = operation.ToLowerInvariant() switch + { + "" or "check" => 4u, + "off" => 1u, + "on" => 2u, + "toggle" => 3u, + _ => null, + }; + if (action is not null) + { + _actions.AllegianceLockAction(action.Value); + return; + } + + if (!operation.Equals("bypass", StringComparison.OrdinalIgnoreCase)) + { + ShowAllegianceHelpRefusal(); + return; + } + + string approved = RemainderAfterFirstArgument(arguments).Trim(); + if (approved.Length == 0) + _actions.AllegianceLockAction(5u); + else if (approved.Equals("clear", StringComparison.OrdinalIgnoreCase)) + _actions.AllegianceLockAction(6u); + else + _actions.SetAllegianceApprovedVassal(approved); + } + + private void ExecuteAllegianceHouse(string arguments) + { + string category = FirstArgument(arguments); + if (category.Length == 0) + { + _actions.AllegianceHouseAction(1u); + return; + } + + string state = FirstArgument(RemainderAfterFirstArgument(arguments)); + uint action = (category.ToLowerInvariant(), state.ToLowerInvariant()) switch + { + ("guest", "open") => 2u, + ("guest", "close") => 3u, + ("storage", "open") => 4u, + ("storage", "close") => 5u, + _ => 0u, + }; + if (action == 0u) + ShowAllegianceHelpRefusal(); + else + _actions.AllegianceHouseAction(action); + } + + private void ExecuteAllegianceMotd(string arguments) + { + string operation = FirstArgument(arguments); + if (operation.Length == 0) + _actions.QueryMotd(); + else if (operation.Equals("set", StringComparison.OrdinalIgnoreCase)) + _actions.SetMotd(RemainderAfterFirstArgument(arguments).Trim()); + else if (operation.Equals("clear", StringComparison.OrdinalIgnoreCase)) + _actions.ClearMotd(); + else + ShowAllegianceHelpRefusal(); + } + + private void ExecuteHouseGuests(string arguments) + { + string operation = FirstArgument(arguments); + string name = RemainderAfterFirstArgument(arguments).Trim(); + if (operation.Equals("add", StringComparison.OrdinalIgnoreCase) + || operation.Equals("remove", StringComparison.OrdinalIgnoreCase)) + { + if (name.Length == 0) + { + ShowClientLocal("Please specify the guest's name."); + return; + } + + if (operation.Equals("add", StringComparison.OrdinalIgnoreCase)) + _actions.AddPermanentGuest(name); + else + _actions.RemovePermanentGuest(name); + return; + } + + if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase)) + _actions.RemoveAllPermanentGuests(); + else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase) + || operation.Equals("show", StringComparison.OrdinalIgnoreCase)) + _actions.RequestFullGuestList(); + else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase)) + _actions.ModifyAllegianceGuestPermission(true); + else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase)) + _actions.ModifyAllegianceGuestPermission(false); + else + ShowHouseHelpRefusal(); + } + + private void ExecuteHouseStorage(string arguments) + { + string operation = FirstArgument(arguments); + string name = RemainderAfterFirstArgument(arguments).Trim(); + if (operation.Equals("add", StringComparison.OrdinalIgnoreCase) + || operation.Equals("remove", StringComparison.OrdinalIgnoreCase)) + { + if (name.Length == 0) + { + ShowClientLocal("Please specify an actual name."); + return; + } + + bool enabled = operation.Equals("add", StringComparison.OrdinalIgnoreCase); + if (name.Equals("-all", StringComparison.OrdinalIgnoreCase)) + { + if (enabled) + _actions.AddAllStoragePermission(); + else + _actions.RemoveAllStoragePermission(); + } + else + { + _actions.ChangeStoragePermission(name, enabled); + } + return; + } + + if (operation.Equals("remove_all", StringComparison.OrdinalIgnoreCase)) + _actions.RemoveAllStoragePermission(); + else if (operation.Equals("list", StringComparison.OrdinalIgnoreCase) + || operation.Equals("show", StringComparison.OrdinalIgnoreCase)) + _actions.RequestFullGuestList(); + else if (operation.Equals("add_allegiance", StringComparison.OrdinalIgnoreCase)) + _actions.ModifyAllegianceStoragePermission(true); + else if (operation.Equals("remove_allegiance", StringComparison.OrdinalIgnoreCase)) + _actions.ModifyAllegianceStoragePermission(false); + else + ShowHouseHelpRefusal(); + } + + private void ExecuteHouseBoot(string arguments) + { + string name = arguments.Trim(); + if (name.Length == 0) + { + ShowHouseHelpRefusal(); + return; + } + + if (name.Equals("-all", StringComparison.OrdinalIgnoreCase)) + _actions.BootEveryone(); + else + _actions.BootSpecificHouseGuest(name); + } + + private void ExecuteHouseHooks(string arguments) + { + string state = FirstArgument(arguments); + if (state.Equals("on", StringComparison.OrdinalIgnoreCase)) + _actions.SetHooksVisibility(true); + else if (state.Equals("off", StringComparison.OrdinalIgnoreCase)) + _actions.SetHooksVisibility(false); + else + ShowHouseHelpRefusal(); + } + + private void ShowAllegianceHelpRefusal() => ShowClientLocal( + "Please see @help Allegiance for more information on how to use this command."); + + private void ShowHouseHelpRefusal() => ShowClientLocal( + "Please see @help House for more information on how to use this command."); + + private void ShowClientLocal(string text) => + _feedback.ShowClientLocalMessage(text); + + private static int RetailStrtolBaseZero(string value) + { + if (string.IsNullOrEmpty(value)) + return 0; + + int index = 0; + int sign = 1; + if (value[index] is '+' or '-') + { + if (value[index] == '-') + sign = -1; + if (++index == value.Length) + return 0; + } + + int numberBase = 10; + if (value[index] == '0') + { + numberBase = 8; + if (index + 2 < value.Length + && value[index + 1] is 'x' or 'X' + && HexDigit(value[index + 2]) >= 0) + { + numberBase = 16; + index += 2; + } + } + + long result = 0; + bool sawDigit = false; + while (index < value.Length) + { + int digit = HexDigit(value[index]); + if (digit < 0 || digit >= numberBase) + break; + sawDigit = true; + result = Math.Min( + (long)int.MaxValue + (sign < 0 ? 1L : 0L), + result * numberBase + digit); + index++; + } + + if (!sawDigit) + return 0; + long signed = sign < 0 ? -result : result; + return (int)Math.Clamp(signed, int.MinValue, int.MaxValue); + + static int HexDigit(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => -1, + }; + } + + private static string FirstArgument(string arguments) + { + string trimmed = arguments.Trim(); + int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']); + return separator < 0 ? trimmed : trimmed[..separator]; + } + + private static string RemainderAfterFirstArgument(string arguments) + { + string trimmed = arguments.Trim(); + int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']); + return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart(); + } +} diff --git a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs index ed534a74..de9325ad 100644 --- a/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs @@ -111,6 +111,23 @@ public static class RetailClientCommandCatalog "/framerate", "/framerate - Toggles the framerate display."); + // ClientCommunicationSystem::DoDay @0x005706F0 ignores argc and toggles + // LScape::m_fAlwaysDaylight plus PlayerModule::PersistentAtDay. + private static readonly Definition Day = AnyArguments( + ClientCommandId.TogglePersistentDaylight, + "/day", + RetailCommandHelpTable.Day); + + // ClientCommunicationSystem::DoRenderOption @0x0057E120 forwards the + // complete argv to GraphicsOptions::HandleRenderOption @0x00455C30. + // That handler owns its usage/error reporting, so every argument shape + // must reach the application executor rather than the generic catalog + // validation refusal. + private static readonly Definition Render = AnyArguments( + ClientCommandId.RenderOption, + "/render